src/AppBundle/Model/Event.php
<?php
/**
* This file contains only the Event class.
*/
declare(strict_types=1);
namespace AppBundle\Model;
use AppBundle\Model\Traits\EventStatTrait;
use AppBundle\Model\Traits\TitleUserTrait;
use DateTime;
use DateTimeZone;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* An Event belongs to one program, and has many participants.
* @ORM\Entity(repositoryClass="AppBundle\Repository\EventRepository")
* @ORM\Table(
* name="event",
* indexes={
* @ORM\Index(name="event_time", columns={"event_start", "event_end"}),
* @ORM\Index(name="event_title", columns={"event_title"}),
* @ORM\Index(name="event_program", columns={"event_program_id"}),
* @ORM\Index(name="event_program_title", columns={"event_program_id", "event_title"}),
* },
* options={"engine":"InnoDB"}
* )
* @ORM\HasLifecycleCallbacks()
*/
class Event
{
/**
* Available metrics type, hard-coded here for accessibility,
* while the logic to compute these stats lives in EventProcessor.
*
* Keys are i18n message keys, values are the 'offset' values.
*
* @see EventProcessor
* @see EventStat
*/
public const AVAILABLE_METRICS = [
'participants' => null,
'new-editors' => 14,
'retention' => 7,
'edits' => null,
'byte-difference' => null,
'pages-created' => null,
'pages-improved' => null,
'pages-created-pageviews' => null,
'pages-improved-pageviews-avg' => 30,
'files-uploaded' => null,
'file-usage' => null,
'pages-using-files' => null,
'pages-using-files-pageviews-avg' => 30,
'items-created' => null,
'items-improved' => null,
];
private const NORMAL_WIKI_METRICS = [
'pages-created',
'pages-improved',
'byte-difference',
'pages-created-pageviews',
'pages-improved-pageviews-avg',
'files-uploaded',
'file-usage',
'pages-using-files',
'pages-using-files-pageviews-avg',
];
/**
* This defines what metrics are available to what wiki families. '*' means all wikis are applicable.
*/
public const WIKI_FAMILY_METRIC_MAP = [
'*' => ['edits', 'participants', 'new-editors', 'retention'],
'wikipedia' => self::NORMAL_WIKI_METRICS,
'wiktionary' => self::NORMAL_WIKI_METRICS,
'wikivoyage' => self::NORMAL_WIKI_METRICS,
'commons' => [
'files-uploaded',
'file-usage',
'pages-using-files',
'pages-using-files-pageviews-avg',
],
'wikidata' => ['items-created', 'items-improved'],
];
/**
* This defines what metrics are visible throughout the application,
* except for reports (which custom define what they include).
* The order specified here is also the order it will appear in the interface.
*/
public const VISIBLE_METRICS = [
'participants',
'new-editors',
'retention',
'pages-created',
'pages-improved',
'files-uploaded',
'file-usage',
'items-created',
'items-improved',
];
/**
* NOTE: Some methods pertaining to titles and Participants live in the TitleUserTrait trait.
*/
use TitleUserTrait;
/**
* Used purely to move out some of the logic to a dedicated file.
*/
use EventStatTrait;
/**
* @ORM\Id
* @ORM\Column(name="event_id", type="integer")
* @ORM\GeneratedValue
* @var int Unique ID of the event.
*/
protected $id;
/**
* Many Events have one Program.
* @ORM\ManyToOne(targetEntity="Program", inversedBy="events")
* @ORM\JoinColumn(name="event_program_id", referencedColumnName="program_id", nullable=false)
* @var Program Program to which this event belongs.
*/
protected $program;
/**
* One Event has many Participants.
* @ORM\OneToMany(
* targetEntity="Participant", mappedBy="event", orphanRemoval=true, cascade={"persist"}, fetch="EXTRA_LAZY"
* )
* @var Collection|Participant[] Participants of this Event.
*/
protected $participants;
/**
* One Event has many EventStats.
* @ORM\OneToMany(targetEntity="EventStat", mappedBy="event", orphanRemoval=true)
* @var Collection|EventStat[] Statistics of this Event.
*/
protected $stats;
/**
* One Event has many EventWikis.
* @ORM\OneToMany(targetEntity="EventWiki", mappedBy="event", orphanRemoval=true, cascade={"persist"})
* @ORM\OrderBy({"domain" = "ASC"})
* @var Collection|EventWiki[] Wikis that this event takes place on.
*/
protected $wikis;
/**
* One Event has many EventCategories.
* @ORM\OneToMany(targetEntity="EventCategory", mappedBy="event", orphanRemoval=true, cascade={"persist"})
* @var Collection|EventCategory[] Categories that this event takes place on.
*/
protected $categories;
/**
* @ORM\Column(name="event_title", type="string", length=255)
* @Assert\Type("string")
* @Assert\Length(max=255)
* @var string The title of the event.
*/
protected $title;
/**
* @ORM\Column(name="event_start", type="datetime", nullable=false)
* @Assert\DateTime(message="error-invalid", payload={"0"="start-date"})
* @Assert\NotNull(message="error-invalid", payload={"0"="start-date"})
* @var DateTime The start date and time of the event.
*/
protected $start;
/**
* @ORM\Column(name="event_end", type="datetime", nullable=false)
* @Assert\DateTime(message="error-invalid", payload={"0"="end-date"})
* @Assert\NotNull(message="error-invalid", payload={"0"="end-date"})
* @var DateTime The end date and time of the event.
*/
protected $end;
/**
* @ORM\Column(
* name="event_timezone",
* type="string",
* length=64,
* options={"default":"UTC"}
* )
* @var string The timezone of the Event. Should be a PHP-supported timezone.
* @see https://secure.php.net/manual/en/timezones.php
*/
protected $timezone;
/**
* @ORM\Column(name="event_updated_at", type="datetime", nullable=true)
* @var DateTime The last time statistics were updated for this event.
*/
protected $updated;
/**
* One Event has many Jobs.
* @ORM\OneToMany(targetEntity="Job", mappedBy="event", orphanRemoval=true)
* @var Collection|Job[] Jobs for this Event.
*/
protected $jobs;
/**
* Event constructor.
* @param Program $program Program that this event belongs to.
* @param string $title Title of the event. This should be unique for the program.
* @param DateTime|string $start Start date of the event.
* @param DateTime|string $end End date of the event.
* @param string $timezone Official timezone code within the tz database.
*/
public function __construct(
Program $program,
?string $title = null,
$start = null,
$end = null,
string $timezone = 'UTC'
) {
$this->program = $program;
$this->setTitle($title);
$this->setTimezone($timezone);
$this->assignDate('start', $start);
$this->assignDate('end', $end);
$this->participants = new ArrayCollection();
$this->stats = new ArrayCollection();
$this->wikis = new ArrayCollection();
$this->categories = new ArrayCollection();
$this->jobs = new ArrayCollection();
}
/**
* The class name of users associated with Events.
* This is referenced in TitleUserTrait.
* @see TitleUserTrait
* @return string
*/
public function getUserClassName(): string
{
return 'Participant';
}
/**
* Get the ID of the event.
* @return int|null
*/
public function getId(): ?int
{
return $this->id;
}
/**
* Get unique cache key for the Event. This is called by Repository::getCacheKey(),
* used when making expensive queries against the replicas.
* @return string
*/
public function getCacheKey(): string
{
return (string)$this->id;
}
/**
* Is the Event valid? If false, statistics will not be able to be generated.
* @return bool
*/
public function isValid(): bool
{
return $this->wikis->count() > 0 &&
null !== $this->start &&
null !== $this->end &&
$this->getStartUTC() < new DateTime() &&
(
$this->participants->count() > 0 ||
$this->getNumCategories(true) > 0
);
}
/***********
* PROGRAM *
***********/
/**
* Get the Program associated with this Event.
* @return Program
*/
public function getProgram(): Program
{
return $this->program;
}
/*********
* DATES *
*********/
/**
* Get the start date of this Event.
* @see self::getStartUTC() if you need to use the datestamp in an SQL query.
* @return DateTime|null
*/
public function getStart(): ?DateTime
{
return $this->start;
}
/**
* Set the start date of this Event.
* @param DateTime|string|null $value
*/
public function setStart($value): void
{
$this->assignDate('start', $value);
}
/**
* Get the start date in UTC. This is what should be used in SQL queries.
* @return DateTime
*/
public function getStartUTC(): DateTime
{
$dateStr = $this->start->format('YmdHis');
$dt = new DateTime($dateStr, new DateTimeZone($this->timezone));
$dt->setTimezone(new DateTimeZone('UTC'));
return $dt;
}
/**
* Get the end date of this Event.
* @see self::getEndUTC() if you need to use the datestamp in an SQL query.
* @return DateTime|null
*/
public function getEnd(): ?DateTime
{
return $this->end;
}
/**
* Get the end date in UTC. This is what should be used in SQL queries.
* @return DateTime
*/
public function getEndUTC(): DateTime
{
$dateStr = $this->end->format('YmdHis');
$dt = new DateTime($dateStr, new DateTimeZone($this->timezone));
$dt->setTimezone(new DateTimeZone('UTC'));
return $dt;
}
/**
* Set the end date of this Event.
* @param DateTime|string|null $value
*/
public function setEnd($value): void
{
$this->assignDate('end', $value);
}
/**
* Convert the given date argument to a DateTime and save to class property.
* @param string $key 'start' or 'end'.
* @param DateTime|string $value
*/
private function assignDate(string $key, $value): void
{
if ($value instanceof DateTime) {
$this->{$key} = $value;
} elseif (is_string($value)) {
$this->{$key} = new DateTime(
$value,
new DateTimeZone('UTC')
);
} else {
$this->{$key} = null;
}
}
/**
* Get the end date of this Event.
* @return string
*/
public function getTimezone(): string
{
return $this->timezone;
}
/**
* Get the display variant of the timezone.
* @return string
*/
public function getDisplayTimezone(): string
{
return str_replace('_', ' ', $this->timezone);
}
/**
* Get the end date of this Event.
* @param string $timezone Official timezone code within the tz database.
*/
public function setTimezone(string $timezone): void
{
$this->timezone = $timezone;
}
/**************
* STATISTICS *
**************/
// @see EventStatTrait
/**************
* CATEGORIES *
**************/
/**
* Get categories belonging to this Event.
* @return Collection|EventCategory[]
*/
public function getCategories(): Collection
{
return $this->categories;
}
/**
* Get the number of categories belonging to this Event.
* @param bool $saved Whether to only count saved categories (have an ID).
* @return int
*/
public function getNumCategories(bool $saved = false): int
{
if (false === $saved) {
return $this->categories->count();
}
return $this->categories->filter(function (EventCategory $category) {
return null !== $category->getId();
})->count();
}
/**
* Get the titles of categories belonging to this Event that are for the specified wiki.
* @param EventWiki $wiki
* @return string[]
*/
public function getCategoryTitlesForWiki(EventWiki $wiki): array
{
return $this->getCategoriesForWiki($wiki)->map(function (EventCategory $category) {
return $category->getTitle(true);
})->toArray();
}
/**
* Get the IDs (in replica database) of categories belonging to this Event that are the specified wiki.
* @param EventWiki $wiki
* @return int[]
*/
public function getCategoryIdsForWiki(EventWiki $wiki): array
{
return $this->getCategoriesForWiki($wiki)->map(function (EventCategory $category) {
return $category->getCategoryId();
})->toArray();
}
/**
* Get categories belonging to this Event that are for the specified wiki.
* @param EventWiki $wiki
* @return Collection of EventCategories.
*/
public function getCategoriesForWiki(EventWiki $wiki): Collection
{
return $this->categories->filter(function (EventCategory $category) use ($wiki) {
return $category->getDomain() === $wiki->getDomain();
});
}
/**
* Add an EventCategory to the Event.
* @param EventCategory $category
*/
public function addCategory(EventCategory $category): void
{
if ($this->categories->contains($category)) {
return;
}
$this->categories->add($category);
}
/**
* Remove an EventCategory from the Event.
* @param EventCategory $category
*/
public function removeCategory(EventCategory $category): void
{
if (!$this->categories->contains($category)) {
return;
}
$this->categories->removeElement($category);
}
/**
* Remove all categories.
*/
public function clearCategories(): void
{
$this->categories->clear();
}
/**
* Before flushing to the database, remove categories for which no relevant EventWiki exists.
* This can happen when removing a wiki from an Event after you had an EventCategory created for the same wiki.
* @ORM\PreFlush()
*/
public function removeInvalidCategories(): void
{
foreach ($this->categories->getIterator() as $category) {
if (1 !== preg_match($this->getAvailableWikiPattern(), $category->getDomain())) {
$this->removeCategory($category);
}
}
}
/****************
* PARTICIPANTS *
****************/
/**
* Get participants of this Event.
* @return Collection|Participant[]
*/
public function getParticipants(): Collection
{
return $this->participants;
}
/**
* Get the number of participants of this Event.
* @return int
*/
public function getNumParticipants(): int
{
// Use the derived participant count if available, otherwise raw count of Participant objects.
// This is to accommodate events with no explicit participants entered (e.g. only a category).
$parStat = $this->getStatistic('participants');
return $parStat && null !== $parStat->getValue() ? (int)$parStat->getValue() : $this->participants->count();
}
/**
* Add an Participant to this Event.
* @param Participant $participant
*/
public function addParticipant(Participant $participant): void
{
if ($this->participants->contains($participant)) {
return;
}
$this->participants->add($participant);
}
/**
* Remove a Participant from this Event.
* @param Participant $participant
*/
public function removeParticipant(Participant $participant): void
{
if (!$this->participants->contains($participant)) {
return;
}
$this->participants->removeElement($participant);
}
/**
* Get the user IDs of all the Participants of this Event.
* @return int[]
*/
public function getParticipantIds(): array
{
return $this->participants->map(function (Participant $participant) {
return $participant->getUserId();
})->toArray();
}
/**
* Get the usernames of the Participants of this Event.
* @return string[]
*/
public function getParticipantNames(): array
{
return $this->participants->map(function (Participant $participant) {
return $participant->getUsername();
})->toArray();
}
/**
* Remove all Participants.
*/
public function clearParticipants(): void
{
$this->participants->clear();
}
/********
* WIKI *
********/
/**
* Get wikis this event is taking place on.
* @return Collection|EventWiki[]
*/
public function getWikis(): Collection
{
return $this->wikis;
}
/**
* Get all of this event's wikis that do not yet have at least one category or (in the case of Wikidata) participant
* defined.
*
* @return Collection
*/
public function getWikisWithoutFilters(): Collection
{
$wikis = $this->getWikisWithoutCategories();
$wikidata = $this->getWikiByDomain('www.wikidata');
if ($wikidata && 0 === $this->participants->count()) {
$wikis->add($wikidata);
}
return $wikis;
}
/**
* Get all of this event's wikis that do not yet have at least one category defined.
* Wikidata is excluded because it can never have categories.
* @return Collection Collection of EventWiki objects.
*/
public function getWikisWithoutCategories(): Collection
{
return $this->wikis->filter(function (EventWiki $eventWiki) {
// Wikidata never has categories, so we don't return it in this list.
if ('www.wikidata' === $eventWiki->getDomain()) {
return false;
}
foreach ($this->categories as $cat) {
if ($cat->getDomain() === $eventWiki->getDomain()) {
return false;
}
}
return true;
});
}
/**
* Get the EventWiki with the given domain that belongs to this Event.
* @param string $domain
* @return EventWiki|false False if not found.
*/
public function getWikiByDomain(string $domain)
{
return $this->wikis->filter(function (EventWiki $wiki) use ($domain) {
return $wiki->getDomain() === $domain;
})->first();
}
/**
* Add an EventWiki to this Event.
* @param EventWiki $wiki
*/
public function addWiki(EventWiki $wiki): void
{
if ($this->wikis->contains($wiki)) {
return;
}
$this->wikis->add($wiki);
}
/**
* Remove an EventWiki from this Event.
* @param EventWiki $wiki
*/
public function removeWiki(EventWiki $wiki): void
{
if (!$this->wikis->contains($wiki)) {
return;
}
$this->wikis->removeElement($wiki);
}
/**
* Get the regex pattern for wikis defined on the Event.
* @return string
*/
public function getAvailableWikiPattern(): string
{
$regex = implode('|', $this->getOrphanWikisAndFamilies()->map(function (EventWiki $wiki) {
// Regex-ify the domain name.
return str_replace('\*', '\w+', preg_quote($wiki->getDomain()));
})->toArray());
return "/$regex/";
}
/***************
* WIKI FAMILY *
***************/
/**
* Get all EventWikis belonging to the Event that represent
* a wiki family (*.wikipedia, *.wiktionary, etc).
* @return Collection|EventWiki[]
*/
public function getFamilyWikis(): Collection
{
return $this->wikis->filter(function (EventWiki $wiki) {
return '*.' === substr((string)$wiki->getDomain(), 0, 2);
});
}
/**
* This method returns all EventWikis associated with the Event, grouped by the name of the associated family.
* It is used for display purposes on the Event page. This does not pay mind to whether there is an EventWiki
* representing a family (e.g. *.wikipedia). For instance, if there are EventWikis for en.wikipedia, fr.wikipedia,
* and commons.wikipedia, the two Wikipedias are grouped together. If there's also a *.wikipedia,
* it is not included in the 'wikipedia' group.
* @return EventWiki[]
*/
public function getWikisByFamily(): array
{
$wikisByFamily = [];
foreach ($this->wikis->getIterator() as $wiki) {
if ($wiki->isFamilyWiki()) {
continue;
}
$familyName = $wiki->getFamilyName();
if (!isset($wikisByFamily[$familyName])) {
$wikisByFamily[$familyName] = [$wiki];
} else {
$wikisByFamily[$familyName][] = $wiki;
}
}
return $wikisByFamily;
}
/**
* Get all associated EventWikis that belong to a family.
* @return Collection|EventWiki[]
*/
public function getChildWikis(): Collection
{
return $this->wikis->filter(function (EventWiki $wiki) {
return $wiki->isChildWiki();
});
}
/**
* Get all EventWikis that are not part of a family that have been added
* to the Event. For instance, if there is an EventWiki for *.wikipedia
* (wikipedia family), a fr.wikipedia EventWiki is not returned, but it
* will if there is not a *.wikipedia EventWiki.
* @return Collection|EventWiki[]
*/
public function getOrphanWikis(): Collection
{
$familyNames = $this->getFamilyWikis()->map(function (EventWiki $eventWiki) {
return $eventWiki->getFamilyName();
});
return $this->wikis->filter(function (EventWiki $wiki) use ($familyNames) {
return null === $wiki->getDomain()
|| !$familyNames->contains($wiki->getFamilyName());
});
}
/**
* Remove all associated EventWikis that belong to a family.
*/
public function clearChildWikis(): void
{
$children = $this->getChildWikis()->toArray();
foreach ($children as $child) {
$this->removeWiki($child);
}
}
/**
* Get EventWikis that are represent a wiki family, or an individual wiki that is not part of a family.
* @return Collection containing EventWikis.
*/
public function getOrphanWikisAndFamilies(): Collection
{
return new ArrayCollection(array_merge(
$this->getFamilyWikis()->toArray(),
$this->getOrphanWikis()->toArray()
));
}
/********
* JOBS *
********/
/**
* Add a Job for this Event.
* @param Job $job
*/
public function addJob(Job $job): void
{
if ($this->jobs->contains($job)) {
return;
}
$this->jobs->add($job);
}
/**
* A convenience method to get the first Job (which should be the only one, for now).
* @return Job|false
*/
public function getJob()
{
return $this->jobs->first();
}
/**
* Get jobs associated with this Event (in theory there should be only one).
* @return Collection of Jobs.
*/
public function getJobs(): Collection
{
return $this->jobs;
}
/**
* Get the number of jobs associated with this Event. (Ideally there'd only be one, but this is here just in case.)
* @return int
*/
public function getNumJobs(): int
{
return $this->jobs->count();
}
/**
* Is there a job associated with this Event?
* @return boolean
*/
public function hasJob(): bool
{
return $this->getNumJobs() > 0;
}
/**
* Remove a Job from the Event. This does NOT kill the job if it is currently running.
* @param Job $job
*/
public function removeJob(Job $job): void
{
if (!$this->jobs->contains($job)) {
return;
}
$this->jobs->removeElement($job);
}
/**
* Remove all Jobs from this Event.
*/
public function clearJobs(): void
{
$this->jobs->clear();
}
/**
* Get stale jobs that have been idling for a long time (specified by $offset).
* @param string $offset String accepted by DateTime constructor.
* @return Collection
*/
public function getStaleJobs(string $offset = '-1 hour'): Collection
{
return $this->jobs->filter(function (Job $job) use ($offset) {
return $job->getSubmitted() <= new DateTime($offset);
});
}
}