Skip to content
Snippets Groups Projects
CalendarDateAssignment.class.php 25.4 KiB
Newer Older
<?php
/**
 * CalendarDateAssignment.class.php - Model class for calendar date assignments.
 *
 * CalendarDateAssignment represents the assignment of a calendar date
 *  to a specific calendar. The calendar is represented by a range-ID
 *  since it can be a personal calendar, course calendar or institute
 *  calendar.
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License as
 * published by the Free Software Foundation; either version 2 of
 * the License, or (at your option) any later version.
 *
 * @author      Moritz Strohm <strohm@data-quest.de>
 * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
 * @category    Stud.IP
 * @since       5.5
 *
 * @property string range_id The range-ID for the assignment.
 * @property string calendar_date_id The ID of the calendar date for the assignment.
 * @property string participation The participation status of the receiver (range_id).
 *     This column is an enum with the following values:
 *     - empty string: Participation status is unknown.
 *     - "ACCEPTED": The calendar owner accepted the date.
 *     - "DECLINED": The calendar owner declined the date.
 *     - "ACKNOWLEDGED": The calendar owner only acknowledged that the date exists
 *           but doesn't necessarily participate in it.
 * @property string mkdate The creation date of the assignment.
 * @property string chdate The modification date of the assignment.
 * @property CalendarDate|null calendar_date The associated calendar date object.
 */
class CalendarDateAssignment extends SimpleORMap implements Event
{
    /**
     * @var bool This attribute allows the suppression of automatic mail sending
     *     when storing or deleting the calendar date assignment.
     *     By default, mails are sent.
     */
    public $suppress_mails = false;

    protected static function configure($config = [])
    {
        $config['db_table'] = 'calendar_date_assignments';

        $config['belongs_to']['calendar_date'] = [
            'class_name'  => CalendarDate::class,
            'foreign_key' => 'calendar_date_id',
            'assoc_func'  => 'find'
        ];
        $config['belongs_to']['user'] = [
            'class_name'  => User::class,
            'foreign_key' => 'range_id',
            'assoc_func'  => 'find'
        ];
        $config['belongs_to']['course'] = [
            'class_name'  => Course::class,
            'foreign_key' => 'range_id',
            'assoc_func'  => 'find'
        ];

        $config['registered_callbacks']['after_create'][] = 'cbSendNewDateMail';
        $config['registered_callbacks']['after_delete'][] = 'cbSendDateDeletedMail';

        parent::configure($config);
    }


    public function cbSendNewDateMail()
    {
        if ($this->suppress_mails) {
            return;
        }
        if ($this->range_id === $this->calendar_date->editor_id) {
            return;
        }
        if (!$this->calendar_date || !$this->user) {
            //Wrong calendar range (not a user) or invalid data set.
            return;
        }

        $template_factory = new Flexi_TemplateFactory($GLOBALS['STUDIP_BASE_PATH'] . '/locale/');

        setTempLanguage($this->range_id);
        $lang_path = getUserLanguagePath($this->range_id);
        $template = $template_factory->open($lang_path . '/LC_MAILS/date_created.php');
        $template->set_attribute('date', $this->calendar_date);
        $template->set_attribute('receiver', $this->user);
        $mail_text = $template->render();
        Message::send(
            '____%system%____',
            [$this->user->username],
            sprintf(_('%s hat einen Termin im Kalender eingetragen'), $this->calendar_date->editor->getFullName()),
            $mail_text
        );

        restoreLanguage();
    }

    public function cbSendDateDeletedMail()
    {
        if ($this->suppress_mails) {
            return;
        }
        $actor = User::findCurrent() ?? $this->calendar_date->editor;
        if ($this->range_id === $actor->id) {
            //The user who deleted the date shall not get notified about this.
            return;
        }
        if (!$this->calendar_date || !$this->user) {
            //Wrong calendar range (not a user) or invalid data set.
            return;
        }

        $template_factory = new Flexi_TemplateFactory($GLOBALS['STUDIP_BASE_PATH'] . '/locale/');

        setTempLanguage($this->range_id);
        $lang_path = getUserLanguagePath($this->range_id);
        $template = $template_factory->open($lang_path . '/LC_MAILS/date_deleted.php');
        $template->set_attribute('date', $this->calendar_date);
        $template->set_attribute('actor', $actor);
        $template->set_attribute('receiver', $this->user);
        $mail_text = $template->render();
        Message::send(
            '____%system%____',
            [$this->user->username],
            sprintf(_('%s hat einen Termin im Kalender gelöscht'), $actor->getFullName()),
            $mail_text
        );

        restoreLanguage();
    }

    /**
     * Sends the participation status of the calendar the date
     * is assigned to. This is only done for user calendars
     * and not for course calendars.
     *
     * @return void
     */
    public function sendParticipationStatus() : void
    {
        if (!($this->user instanceof User)) {
            //The calendar date is assigned to a course calendar.
            return;
        }

        if (!$this->participation || $this->participation === 'ACKNOWLEDGED') {
            //Nothing shall be done in these two cases.
            return;
        }

        if (empty($this->calendar_date->author->username)) {
            //The calendar date has no author.
            return;
        }
        if ($this->range_id === $this->calendar_date->author_id) {
            //The author of the date changed their participation status.
            //So they know what they did and do not have to be notified.
            return;
        }

        $template_factory = new Flexi_TemplateFactory($GLOBALS['STUDIP_BASE_PATH'] . '/locale/');

        setTempLanguage($this->range_id);
        $lang_path = getUserLanguagePath($this->range_id);
        $template = $template_factory->open($lang_path . '/LC_MAILS/date_participation.php');
        $template->set_attribute('date_assignment', $this);
        $mail_text = $template->render();

        $subject = '';
        if ($this->participation === 'ACCEPTED') {
            $subject = sprintf(
                _('%1$s hat Ihren Termin am %2$s angenommen'),
                $this->user->getFullName(),
                date('d.m.Y', $this->calendar_date->begin)
            );
        } elseif ($this->participation === 'DECLINED') {
            $subject = sprintf(
                _('%1$s hat Ihren Termin am %2$s abgelehnt'),
                $this->user->getFullName(),
                date('d.m.Y', $this->calendar_date->begin)
            );
        }

        Message::send(
            '____%system%____',
            [$this->calendar_date->author->username],
            $subject,
            $mail_text
        );

        restoreLanguage();
    }

    /**
     * Retrieves calendar dates inside a specified time range that are present in the calendar of a
     * course or user. They can additionally be filtered by the access level and declined events
     * can be filtered out, too.
     *
     * @param DateTime $begin The beginning of the time range.
     *
     * @param DateTime $end The end of the time range.
     *
     * @param string $range_id The ID of the course or user whose calendar dates shall be retrieved.
     *
     * @param array $access_levels The access level filter: Only include calendar dates that have one of the
     *     access levels in the list.
     *
     * @param bool $with_declined Include declined calendar dates (true) or filter them out (false).
     *     Defaults to false.
     *
     * @return CalendarDateAssignment[] A list of calendar date assignments in the time range that match the filters.
     */
    public static function getEvents(
        DateTime $begin,
        DateTime $end,
        string $range_id,
        array $access_levels = ['PUBLIC', 'PRIVATE', 'CONFIDENTIAL'],
        bool $with_declined = false
    ) : array
    {
        // Always use the timezone of the server:
        $local_timezone = (new DateTime())->getTimezone();
        $begin->setTimezone($local_timezone);
        $end->setTimezone($local_timezone);

        // one whole day as minimum (begin and end time stamp at the same day)
        $begin->modify('midnight');
        $end->modify('tomorrow -1 second');

        $sql = "JOIN `calendar_dates`
            ON calendar_date_id = `calendar_dates`.`id`
            WHERE
            `calendar_date_assignments`.`range_id` = :range_id
            AND
            `access` IN ( :access_levels ) ";
        if (!$with_declined) {
            $sql .= "AND `calendar_date_assignments`.`participation` <> 'DECLINED' ";
        }
        $sql_single = $sql . " AND
                `calendar_dates`.`begin` < :end  AND :begin < `calendar_dates`.`end`
            ";
            'range_id'      => $range_id,
            'begin'         => $begin->getTimestamp(),
            'end'           => $end->getTimestamp(),
            'access_levels' => $access_levels
        ]);

        $sql_repetition = $sql . " AND `calendar_dates`.`begin` < :end AND `calendar_dates`.`repetition_type` IN ('DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY')
                    AND `calendar_dates`.`repetition_end` > :begin
            ";

        $events = array_merge($events, self::findBySql($sql_repetition, [
            'range_id'      => $range_id,
            'begin'         => $begin->getTimestamp(),
            'end'           => $end->getTimestamp(),
            'access_levels' => $access_levels
        ]));

        $m_start = clone $begin;
        $m_end = clone $end;
        $events_created = [];
        while ($m_start < $m_end) {

            foreach ($events as $event) {
                $e_start = clone $event->getBegin();
                $e_end = clone $event->getEnd();
                $e_expire = $event->getExpire();

                $cal_start = DateTimeImmutable::createFromMutable($m_start);
                $cal_end = DateTimeImmutable::createFromMutable($m_start)->modify('tomorrow -1 second');
                $cal_noon = $cal_start->modify('noon');
                // single events or first event
                if (
                    ($e_start >= $cal_start && $e_end <= $cal_end)
                    || ($e_start >= $cal_start && $e_start <= $cal_end)
                    || ($e_start < $cal_start && $e_end > $cal_end)
                    || ($e_end > $cal_start && $e_start <= $cal_end)
                ) {
                    // exception for first event or single event
                    if (!$event->calendar_date->exceptions->findOneBy('date', $cal_start->format('Y-m-d'))
                        && !isset($events_created[$event->calendar_date->id])) {
                            $events_created[$event->calendar_date->id . '_' . $event->calendar_date->begin] = $event;
                    }
                } elseif ($e_expire > $cal_start) {
                    $events_created = array_merge($events_created, self::getRepetition($event, $cal_noon));
                }
            }

            $m_start->modify('+1 day');
        }

        return $events_created;
    }

    private static function getRepetition(
        CalendarDateAssignment $date,
        DateTimeImmutable $cal_noon,
        bool $calc_prev = true
    ): array
    {
        $rep_dates = [];
        $ts = $date->getNoonDate();
        if ($cal_noon >= $ts) {
            if ($date->isRepeatedAtDate($cal_noon)) {
                $rep_dates = array_merge($rep_dates, self::createRecurrentDate($date, $cal_noon));
            }
            if ($calc_prev) {
                $rep_noon = $cal_noon->modify(sprintf('-%s days', $date->getDurationDays()));
                $rep_dates = array_merge(
                    $rep_dates,
                    self::getRepetition(
                        $date,
                        $rep_noon,
                        false
                    )
                );
            }
        }
        return $rep_dates;
    }

    private function isRepeatedAtDate(DateTimeImmutable $cal_date): bool
    {
        $ts = $this->getNoonDate();
        $pos = 1;
        switch ($this->getRepetitionType()) {
            case 'DAILY':
                $pos = $cal_date->diff($ts)->days % $this->calendar_date->interval;
                break;
            case 'WEEKLY':
                $cal_ts = $cal_date->modify('monday this week noon');
                if ($cal_date >= $this->getBegin()) {
                    $pos = $cal_ts->diff($ts)->days % ($this->calendar_date->interval * 7);
                    if (
                        $pos === 0
                        && strpos($this->calendar_date->days, $cal_date->format('N')) === false
                    ) {
                        $pos = 1;
                    }
                }
                break;
            case 'MONTHLY':
                $cal_ts = $cal_date->modify('first day of this month noon');
                $diff = $cal_ts->diff($ts);
                $pos = ($diff->m + $diff->y * 12) % $this->calendar_date->interval;
                if ($pos === 0) {
                    if (strlen($this->calendar_date->days)) {
                        $cal_ts_dom = $cal_ts->modify(sprintf('%s %s of this month noon',
                            $this->calendar_date->getOrdinalName(),
                            $this->calendar_date->getWeekdayName()));
                        if ($cal_ts_dom != $cal_date->setTime(12, 0)) {
                            $pos = 1;
                        }
                    } elseif ($this->calendar_date->offset !== $cal_date->format('j')) {
                        $pos = 1;
                    }
                }
                break;
            case 'YEARLY':
                $cal_ts = $cal_date->modify('first day of this year noon');
                $diff = $cal_ts->diff($ts);
                $pos = $diff->y % $this->calendar_date->interval;
                if ($pos === 0) {
                    if (strlen($this->calendar_date->days)) {
                        $ts_doy = $ts->modify(sprintf('%s %s of %s-%s noon',
                            $this->calendar_date->getOrdinalName(),
                            $this->calendar_date->getWeekdayName(),
                            $cal_date->format('Y'),
                            $this->calendar_date->month));
                        if ($ts_doy->format('n-j') !== $cal_date->format('n-j')) {
                            $pos = 1;
                        }
                    } elseif (
                        $cal_date->format('n-j') !== sprintf(
                            '%s-%s',
                            $this->calendar_date->month,
                            $this->calendar_date->offset
                        )
                    ) {
                        $pos = 1;
                    }
                }
                break;
            default:
                $pos = 1;
        }
        //Also check for exceptions before returning:
        return $pos === 0
            && !$this->calendar_date->exceptions->findOneBy(
                'date',
                $cal_date->format('Y-m-d'));
    }

    private static function createRecurrentDate(
        CalendarDateAssignment $date,
        DateTimeImmutable $date_time
    ) : array
    {
        $date_begin = $date->getBegin();
        $date_end = $date->getEnd();

        $rec_date = clone $date;
        $time_begin = $date_begin->format('H:i:s');
        $time_end = $date_end->format('H:i:s');

        $rec_date_begin = $date_time->modify(sprintf('today %s', $time_begin));
        $rec_date_end = $rec_date_begin->add($date->getDuration())->modify($time_end);

        $rec_date->calendar_date->begin = $rec_date_begin->getTimestamp();
        $rec_date->calendar_date->end = $rec_date_end->getTimestamp();
        $index = $date->calendar_date->id . '_' . $rec_date_begin->getTimestamp();
        return [$index => $rec_date];
    }

    //Event interface implementation:

    public function getObjectId() : string
    {
        return (string)$this->id;
    }

    public function getPrimaryObjectID(): string
    {
        return $this->calendar_date_id;
    }

    public function getObjectClass(): string
    {
        return static::class;
    }

    public function getTitle() : string
    {
        return $this->calendar_date->title ?? '';
    }

    public function getBegin(): DateTime
    {
        $begin = new DateTime();
        $begin->setTimestamp($this->calendar_date->begin ?? 0);
        return $begin;
    }

    public function getEnd(): DateTime
    {
        $end = new DateTime();
        $end->setTimestamp($this->calendar_date->end ?? 0);
        return $end;
    }

    public function getDuration(): DateInterval
    {
        $begin = $this->getBegin();
        $end = $this->getEnd();
        return $begin->diff($end);
    }

    /**
     * Returns the "extent" in days of this date.
     *
     * @return int The "extent" in days of this date.
     */
    public function getDurationDays(): int
    {
        return self::getExtent($this->getEnd(), $this->getBegin());
    }

    /**
     * Returns the "extent" in days of this date.
     * The extent is the number of days a date is displayed in a calendar.
     *
     * @return int The "extent" in days of this date.
     */
    public static function getExtent(DateTimeInterface $date_begin, DateTimeInterface $date_end): int
    {
        $days_duration = $date_end->diff($date_begin)->days;
        if ($date_begin->format('His') > $date_end->format('His')) {
            $days_duration += 1;
        }
        return $days_duration;
    }

    public function getLocation(): string
    {
        return $this->calendar_date->location ?? '';
    }

    public function getUniqueId(): string
    {
        return $this->calendar_date->unique_id ?? '';
    }

    public function getDescription(): string
    {
        return $this->calendar_date->description ?? '';
    }

    public function getAdditionalDescriptions(): array
    {
        return [
            _('Kategorie')    => $this->calendar_date->getCategoryAsString(),
            _('Sichtbarkeit') => $this->calendar_date->getVisibilityAsString(),
            _('Wiederholung') => $this->calendar_date->getRepetitionAsString()
        ];
    }

    public function isAllDayEvent(): bool
    {
        $begin = $this->getBegin();
            return false;
        }
    }

    public function isWritable(string $user_id): bool
    {
        if ($this->calendar_date->author_id === $user_id) {
            //The author may always modify one of their dates:
            return true;
        }
        if ($this->calendar_date->isWritable($user_id)) {
            //The date is writable.
            return true;
        }

        //The user referenced by $user_id is not the author of the date.
        //Check if they have write permissions to the calendar where the date is assigned to:
        if ($this->user instanceof User) {
            //It is a personal calendar. Check if the owner of the calendar has granted write permissions
            //to the user:
            return Contact::countBySQL(
                "`owner_id` = :owner_id AND `user_id` = :user_id
                AND `calendar_permissions` = 'WRITE'",
                ['owner_id' => $this->range_id, 'user_id' => $user_id]
            ) > 0;
        } elseif ($this->course instanceof Course) {
            //It is a course calendar.
            return $GLOBALS['perm']->have_studip_perm('dozent', $this->range_id, $user_id);
        }

        //No write permissions are granted.
        return false;
    }

    public function getCreationDate(): DateTime
    {
        $mkdate = new DateTime();
        $mkdate->setTimestamp($this->calendar_date->mkdate ?? 0);
        return $mkdate;
    }

    public function getModificationDate(): DateTime
    {
        $chdate = new DateTime();
        $chdate->setTimestamp($this->calendar_date->chdate ?? 0);
        return $chdate;
    }

    public function getImportDate(): DateTime
    {
        $import_date = new DateTime();
        $import_date->setTimestamp($this->calendar_date->import_date ?? 0);
        return $import_date;
    }

    public function getAuthor(): ?User
    {
        return $this->calendar_date->author ?? null;
    }

    public function getEditor(): ?User
    {
        return $this->calendar_date->editor ?? null;
    }

    /**
     * TODO calculate end of repetition for different types of repetition
     * @return float|int|object
     */
    public function getExpire()
    {
        if ($this->calendar_date->repetition_end > 0) {
            $expire = $this->calendar_date->repetition_end;
        } else {
            $expire = CalendarDate::NEVER_ENDING;
        }

        $end = new DateTime();
        $end->setTimestamp($expire);
        return $end;
    }

    // TODO calculate ts for monthly and yearly repetition
    public function getNoonDate()
    {
        $ts = DateTimeImmutable::createFromMutable($this->getBegin());
        switch ($this->calendar_date->repetition_type) {
            case 'DAILY':
                return $ts->modify('noon');
            case 'WEEKLY':
                return  $ts->modify('monday this week noon');
            case 'MONTHLY':
                return $ts->modify('first day of this month noon');
            case 'YEARLY':
                return $ts->modify('first day of this year noon');
            default:
                return $ts;
        }
    }

    /**
     * Returns the type of repetition.
     *
     * @return string The type of repetition.
     */
    public function getRepetitionType(): string
    {
        return $this->calendar_date->repetition_type;
    }

    public function toEventData(string $user_id): \Studip\Calendar\EventData
    {
        $begin = $this->getBegin();
        $end = $this->getEnd();


        $hide_confidential_data = $this->calendar_date->access === 'CONFIDENTIAL'
            && $user_id !== $this->calendar_date->author_id;

        $event_classes = ['user-date'];

        $text_colour = '#000000';
        $background_colour = '#ffffff';
        $border_colour = '#000000';
        if (!$hide_confidential_data) {
            if ($this->calendar_date->user_category) {
                //The date belongs to a personal category that gets a grey colour.
                $background_colour = '#a7abaf';
                $border_colour     = '#a7abaf';
            } else {
                //The date belongs to a system category that has its own colours.
                $text_colour = $GLOBALS['PERS_TERMIN_KAT'][$this->calendar_date->category]['fgcolor'] ?? $text_colour;
                $background_colour = $GLOBALS['PERS_TERMIN_KAT'][$this->calendar_date->category]['bgcolor'] ?? $background_colour;
                $border_colour = $GLOBALS['PERS_TERMIN_KAT'][$this->calendar_date->category]['border_color'] ?? $border_colour;
                $event_classes[] = sprintf('user-date-category%d', $this->calendar_date->category);
            }
        }

        $show_url_params = [];
        if ($this->calendar_date->repetition_type) {
            $show_url_params['selected_date'] = $begin->format('Y-m-d');
        }

        return new \Studip\Calendar\EventData(
            $begin,
            $end,
            !$hide_confidential_data ? $this->getTitle() : '',
            $event_classes,
            $text_colour,
            $background_colour,
            $this->isWritable($user_id),
            CalendarDateAssignment::class,
            $this->id,
            CalendarDate::class,
            $this->calendar_date_id,
            'user',
            $this->range_id ?? '',
            [
                'show'   => URLHelper::getURL('dispatch.php/calendar/date/index/' . $this->calendar_date_id, $show_url_params)
            ],
            [
                'resize_dialog' => URLHelper::getURL('dispatch.php/calendar/date/move/' . $this->calendar_date_id),
                'move_dialog'   => URLHelper::getURL('dispatch.php/calendar/date/move/' . $this->calendar_date_id)
            ],
            $this->participation === 'DECLINED' ? 'decline-circle-full' : '',
            $border_colour,
            $all_day
        );
    }

    public function getRangeName() : string
    {
        if ($this->course instanceof Course) {
            return $this->course->getFullName();
        } elseif ($this->user instanceof User) {
            return $this->user->getFullName();
        }
        return '';
    }

    public function getRangeAvatar() : ?Avatar
    {
        if ($this->course instanceof Course) {
            return CourseAvatar::getAvatar($this->range_id);
        } elseif ($this->user instanceof User) {
            return Avatar::getAvatar($this->range_id);
        }
        return null;
    }

    public function getParticipationAsString() : string
    {
        if ($this->participation === '') {
            return _('Abwartend');
        } elseif ($this->participation === 'ACKNOWLEDGED') {
            return _('Angenommen (keine Teilnahme)');
        } elseif ($this->participation === 'ACCEPTED') {
            return _('Angenommen');
        } elseif ($this->participation === 'DECLINED') {
            return _('Abgelehnt');
        }
        return '';
    }
}