from datetime import datetime, timedelta, timezone from enum import IntEnum from typing import Optional, Tuple from flaschengeist.models import UtcDateTime from flaschengeist.models.user import User from werkzeug.exceptions import BadRequest, Conflict, NotFound from sqlalchemy.exc import IntegrityError from sqlalchemy.orm.util import was_deleted from flaschengeist import logger from flaschengeist.database import db from flaschengeist.plugins import before_delete_user from flaschengeist.plugins.scheduler import scheduled from . import EventPlugin from .models import EventType, Event, Invitation, Job, JobType, Service # STUB def _(x): return x class NotifyType(IntEnum): # Invitations 0x00..0x0F INVITE = 0x01 TRANSFER = 0x02 # Invitation responsed 0x10..0x1F INVITATION_ACCEPTED = 0x10 INVITATION_REJECTED = 0x11 @before_delete_user def clear_services(user): """ This is called when an user got deleted so it cleans future services. It removes the deleted user from all future events. """ logger.debug(f"Clear deleted user {user.userid} from future events.") _, jobs = get_jobs(user, UtcDateTime.current_utc()) for job in jobs: job.services = list(filter(lambda s: s.user_ != user, job.services)) db.session.commit() def update(): db.session.commit() def get_event_types(): return EventType.query.all() def get_event_type(identifier): """Get EventType by ID (int) or name (string)""" if isinstance(identifier, int): et = EventType.query.get(identifier) elif isinstance(identifier, str): et = EventType.query.filter(EventType.name == identifier).one_or_none() else: logger.debug("Invalid identifier type for EventType") raise BadRequest if not et: raise NotFound return et def create_event_type(name): try: event = EventType(name=name) db.session.add(event) db.session.commit() return event except IntegrityError: raise Conflict("Name already exists") def rename_event_type(identifier, new_name): event_type = get_event_type(identifier) event_type.name = new_name try: db.session.commit() except IntegrityError: raise Conflict("Name already exists") def delete_event_type(name): event_type = get_event_type(name) db.session.delete(event_type) try: db.session.commit() except IntegrityError: raise BadRequest("Type still in use") def get_job_types(): return JobType.query.all() def get_job_type(type_id): job_type = JobType.query.get(type_id) if not job_type: raise NotFound return job_type def create_job_type(name): try: job_type = JobType(name=name) db.session.add(job_type) db.session.commit() return job_type except IntegrityError: raise BadRequest("Name already exists") def rename_job_type(name, new_name): job_type = get_job_type(name) job_type.name = new_name try: db.session.commit() except IntegrityError: raise BadRequest("Name already exists") def delete_job_type(name): job_type = get_job_type(name) db.session.delete(job_type) try: db.session.commit() except IntegrityError: raise BadRequest("Type still in use") def clear_backup(event: Event): for job in event.jobs: services = [] for service in job.services: if not service.is_backup: services.append(service) job.services = services def get_event(event_id, with_backup=False) -> Event: event = Event.query.get(event_id) if event is None: raise NotFound if not with_backup: clear_backup(event) return event def get_templates(): return Event.query.filter(Event.is_template == True).all() def get_events( start: Optional[datetime] = None, end: Optional[datetime] = None, limit: Optional[int] = None, offset: Optional[int] = None, descending: Optional[bool] = False, with_backup=False, ) -> Tuple[int, list[Event]]: """Query events which start from begin until end Args: start (datetime): Earliest start end (datetime): Latest start with_backup (bool): Export also backup services Returns: collection of Event objects """ query = Event.query.filter(Event.is_template.__eq__(False)) if start is not None: query = query.filter(start <= Event.start) if end is not None: query = query.filter(Event.start < end) elif start is None: # Neither start nor end was given query = query.filter(datetime.now() <= Event.start) if descending: query = query.order_by(Event.start.desc()) else: query = query.order_by(Event.start) count = query.count() if limit is not None: query = query.limit(limit) if offset is not None and offset > 0: query = query.offset(offset) events: list[Event] = query.all() if not with_backup: for event in events: clear_backup(event) logger.debug(end) for event in events: logger.debug(f"{event.start} < {end} = {event.start < end}") return count, events def delete_event(event_id): """Delete event with given ID Args: event_id: id of Event to delete Raises: NotFound if not found """ event = get_event(event_id, True) for job in event.jobs: delete_job(job) db.session.delete(event) db.session.commit() def create_event(event_type, start, end=None, jobs=[], is_template=None, name=None, description=None): try: logger.debug(event_type) event = Event( start=start, end=end, name=name, description=description, type=event_type, is_template=is_template, jobs=jobs, ) db.session.add(event) db.session.commit() return event except IntegrityError: logger.debug("Database error when creating new event", exc_info=True) raise BadRequest def get_job(job_id, event_id=None) -> Job: query = Job.query.filter(Job.id == job_id) if event_id is not None: query = query.filter(Job.event_id_ == event_id) job = query.one_or_none() if job is None: raise NotFound return job def get_jobs(user, start=None, end=None, limit=None, offset=None, descending=None) -> Tuple[int, list[Job]]: query = Job.query.join(Service).filter(Service.user_ == user) if start is not None: query = query.filter(start <= Job.end) if end is not None: query = query.filter(end >= Job.start) if descending is not None: query = query.order_by(Job.start.desc(), Job.type) else: query = query.order_by(Job.start, Job.type) count = query.count() if limit is not None: query = query.limit(limit) if offset is not None: query = query.offset(offset) return count, query.all() def add_job(event, job_type, required_services, start, end=None, comment=None): job = Job( required_services=required_services, type_=job_type, start=start, end=end, comment=comment, ) event.jobs.append(job) update() return job def update(): try: db.session.commit() except IntegrityError: logger.debug( "Error, looks like a Job with that type already exists on an event", exc_info=True, ) raise BadRequest() def delete_job(job: Job): for service in job.services: unassign_job(service=service, notify=True) for invitation in job.invitations_: respond_invitation(invitation, False) db.session.delete(job) db.session.commit() def assign_job(job: Job, user, value, is_backup=False): assert value > 0 service = Service.query.get((job.id, user.id_)) if service: service.value = value service.is_backup = is_backup else: job.services.append(Service(user_=user, value=value, is_backup=is_backup, job_=job)) db.session.commit() def unassign_job(job: Job = None, user=None, service=None, notify=False): if service is None: assert job is not None and user is not None service = Service.query.get((job.id, user.id_)) else: user = service.user_ if not service: raise BadRequest event_id = service.job_.event_id_ db.session.delete(service) db.session.commit() if notify: EventPlugin.plugin.notify(user, "Your assignmet was cancelled", {"event_id": event_id}) def invite(job: Job, invitee, inviter, transferee=None): inv = Invitation(job_=job, inviter_=inviter, invitee_=invitee, transferee_=transferee) db.session.add(inv) update() if transferee is None: EventPlugin.plugin.notify(invitee, _("Job invitation"), {"type": NotifyType.INVITE, "invitation": inv.id}) else: EventPlugin.plugin.notify(invitee, _("Job transfer"), {"type": NotifyType.TRANSFER, "invitation": inv.id}) return inv def get_invitation(id: int): inv: Invitation = Invitation.query.get(id) if inv is None: raise NotFound return inv def get_invitations(user: User): return Invitation.query.filter( (Invitation.invitee_ == user) | (Invitation.inviter_ == user) | (Invitation.transferee_ == user) ).all() def cancel_invitation(inv: Invitation): db.session.delete(inv) db.session.commit() def respond_invitation(invite: Invitation, accepted=True): inviter = invite.inviter_ job = invite.job_ db.session.delete(invite) db.session.commit() if not was_deleted(invite): raise Conflict if not accepted: EventPlugin.plugin.notify( inviter, _("Invitation rejected"), { "type": NotifyType.INVITATION_REJECTED, "event": job.event_id_, "job": invite.job_id, "invitee": invite.invitee_id, }, ) else: if invite.transferee_id is None: assign_job(job, invite.invitee_, 1) else: service = filter(lambda s: s.userid == invite.transferee_id, job.services) if not service: raise Conflict unassign_job(job, invite.transferee_, service[0], True) assign_job(job, invite.invitee_, service[0].value) EventPlugin.plugin.notify( inviter, _("Invitation accepted"), { "type": NotifyType.INVITATION_ACCEPTED, "event": job.event_id_, "job": invite.job_id, "invitee": invite.invitee_id, }, ) @scheduled(id="dev.flaschengeist.events.assign_backups", minutes=30) def assign_backups(): now = datetime.now(tz=timezone.utc) # now + backup_time + next cron tick start = now + timedelta(hours=16) + timedelta(minutes=30) services = Service.query.filter(Service.is_backup == True).join(Job).filter(Job.start <= start).all() for service in services: if service.job_.start <= now or service.job_.is_full(): EventPlugin.plugin.notify( service.user_, "Your backup assignment was cancelled.", {"event_id": service.job_.event_id_}, ) logger.debug(f"Service is outdated or full, removing. {service.serialize()}") db.session.delete(service) else: service.is_backup = False logger.debug(f"Service not full, assigning backup. {service.serialize()}") EventPlugin.plugin.notify( service.user_, "Your backup assignment was accepted.", {"event_id": service.job_.event_id_}, ) db.session.commit()