diff --git a/.gitignore b/.gitignore index de1837c..ddadd2c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ node_modules/ yarn-error.log # No need, this is done by user yarn.lock + +# Backend +*.egg-info +__pycache__ \ No newline at end of file diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..c9f9f31 --- /dev/null +++ b/.npmignore @@ -0,0 +1,2 @@ +yarn-error.log +backend/ \ No newline at end of file diff --git a/backend/flaschengeist_events/__init__.py b/backend/flaschengeist_events/__init__.py new file mode 100644 index 0000000..a1f386f --- /dev/null +++ b/backend/flaschengeist_events/__init__.py @@ -0,0 +1,22 @@ +"""Events plugin + +Provides duty schedule / duty roster functions +""" +from flask import Blueprint, current_app +from werkzeug.local import LocalProxy + +from flaschengeist.plugins import Plugin +from . import permissions, models + + +class EventPlugin(Plugin): + name = "events" + id = "dev.flaschengeist.plugins.events" + plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][EventPlugin.name]) + permissions = permissions.permissions + blueprint = Blueprint(name, __name__) + models = models + + def __init__(self, cfg): + super(EventPlugin, self).__init__(cfg) + from . import routes diff --git a/backend/flaschengeist_events/event_controller.py b/backend/flaschengeist_events/event_controller.py new file mode 100644 index 0000000..a59dfa8 --- /dev/null +++ b/backend/flaschengeist_events/event_controller.py @@ -0,0 +1,397 @@ +from datetime import datetime, timedelta, timezone +from enum import IntEnum +from typing import Optional, Tuple + +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.utils.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 + + +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) + print(job_type) + 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_id_) + else: + query = query.order_by(Job.start, Job.type_id_) + 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 + 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 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 +def assign_backups(): + logger.debug("Notifications") + 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() diff --git a/backend/flaschengeist_events/models.py b/backend/flaschengeist_events/models.py new file mode 100644 index 0000000..97c06ca --- /dev/null +++ b/backend/flaschengeist_events/models.py @@ -0,0 +1,140 @@ +from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 + +from datetime import datetime +from typing import Optional, Union + +from sqlalchemy import UniqueConstraint + +from flaschengeist.models import ModelSerializeMixin, UtcDateTime, Serial +from flaschengeist.models.user import User +from flaschengeist.database import db + +######### +# Types # +######### + +_table_prefix_ = "events_" + + +class EventType(db.Model, ModelSerializeMixin): + __tablename__ = _table_prefix_ + "event_type" + id: int = db.Column(Serial, primary_key=True) + name: str = db.Column(db.String(30), nullable=False, unique=True) + + +class JobType(db.Model, ModelSerializeMixin): + __tablename__ = _table_prefix_ + "job_type" + id: int = db.Column(Serial, primary_key=True) + name: str = db.Column(db.String(30), nullable=False, unique=True) + + +######## +# Jobs # +######## + + +class Service(db.Model, ModelSerializeMixin): + __tablename__ = _table_prefix_ + "service" + userid: str = "" + is_backup: bool = db.Column(db.Boolean, default=False) + value: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False) + + _job_id = db.Column( + "job_id", + Serial, + db.ForeignKey(f"{_table_prefix_}job.id"), + nullable=False, + primary_key=True, + ) + _user_id = db.Column("user_id", Serial, db.ForeignKey("user.id"), nullable=False, primary_key=True) + + user_: User = db.relationship("User") + job_: Job = db.relationship("Job") + + @property + def userid(self): + return self.user_.userid + + +class Job(db.Model, ModelSerializeMixin): + __tablename__ = _table_prefix_ + "job" + + id: int = db.Column(Serial, primary_key=True) + start: datetime = db.Column(UtcDateTime, nullable=False) + end: Optional[datetime] = db.Column(UtcDateTime) + type: Union[JobType, int] = db.relationship("JobType") + comment: Optional[str] = db.Column(db.String(256)) + locked: bool = db.Column(db.Boolean(), default=False, nullable=False) + services: list[Service] = db.relationship( + "Service", back_populates="job_", cascade="save-update, merge, delete, delete-orphan" + ) + required_services: float = db.Column(db.Numeric(precision=4, scale=2, asdecimal=False), nullable=False) + + event_ = db.relationship("Event", back_populates="jobs") + event_id_ = db.Column("event_id", Serial, db.ForeignKey(f"{_table_prefix_}event.id"), nullable=False) + type_id_ = db.Column("type_id", Serial, db.ForeignKey(f"{_table_prefix_}job_type.id"), nullable=False) + + invitations_ = db.relationship("Invitation", cascade="all,delete,delete-orphan", back_populates="job_") + + __table_args__ = (UniqueConstraint("type_id", "start", "event_id", name="_type_start_uc"),) + + +########## +# Events # +########## +class Event(db.Model, ModelSerializeMixin): + """Model for an Event""" + + __tablename__ = _table_prefix_ + "event" + id: int = db.Column(Serial, primary_key=True) + start: datetime = db.Column(UtcDateTime, nullable=False) + end: Optional[datetime] = db.Column(UtcDateTime) + name: Optional[str] = db.Column(db.String(255)) + description: Optional[str] = db.Column(db.String(512)) + type: Union[EventType, int] = db.relationship("EventType") + is_template: bool = db.Column(db.Boolean, default=False) + jobs: list[Job] = db.relationship( + "Job", + back_populates="event_", + cascade="all,delete,delete-orphan", + order_by="[Job.start, Job.end]", + ) + # Protected for internal use + _type_id = db.Column( + "type_id", + Serial, + db.ForeignKey(f"{_table_prefix_}event_type.id", ondelete="CASCADE"), + nullable=False, + ) + + +class Invitation(db.Model, ModelSerializeMixin): + __tablename__ = _table_prefix_ + "invitation" + + id: int = db.Column(Serial, primary_key=True) + job_id: int = db.Column(Serial, db.ForeignKey(_table_prefix_ + "job.id"), nullable=False) + # Dummy properties for API export + invitee_id: str = None # User who was invited to take over + inviter_id: str = None # User who invited the invitee + transferee_id: Optional[str] = None # In case of a transfer: The user who is transfered out of the job + # Not exported properties for backend use + job_: Job = db.relationship(Job, foreign_keys="Invitation.job_id") + invitee_: User = db.relationship("User", foreign_keys="Invitation._invitee_id") + inviter_: User = db.relationship("User", foreign_keys="Invitation._inviter_id") + transferee_: User = db.relationship("User", foreign_keys="Invitation._transferee_id") + # Protected properties needed for internal use + _invitee_id = db.Column("invitee_id", Serial, db.ForeignKey("user.id"), nullable=False) + _inviter_id = db.Column("inviter_id", Serial, db.ForeignKey("user.id"), nullable=False) + _transferee_id = db.Column("transferee_id", Serial, db.ForeignKey("user.id")) + + @property + def invitee_id(self): + return self.invitee_.userid + + @property + def inviter_id(self): + return self.inviter_.userid + + @property + def transferee_id(self): + return self.transferee_.userid if self.transferee_ else None diff --git a/backend/flaschengeist_events/permissions.py b/backend/flaschengeist_events/permissions.py new file mode 100644 index 0000000..3eb81b6 --- /dev/null +++ b/backend/flaschengeist_events/permissions.py @@ -0,0 +1,28 @@ +CREATE = "events_create" +"""Can create events""" + +EDIT = "events_edit" +"""Can edit events""" + +DELETE = "events_delete" +"""Can delete events""" + +EVENT_TYPE = "events_event_type" +"""Can create and edit EventTypes""" + +JOB_TYPE = "events_job_type" +"""Can create and edit JobTypes""" + +ASSIGN = "events_assign" +"""Can self assign to jobs""" + +ASSIGN_OTHER = "events_assign_other" +"""Can assign other users to jobs""" + +SEE_BACKUP = "events_see_backup" +"""Can see users assigned as backup""" + +LOCK_JOBS = "events_lock_jobs" +"""Can lock jobs, no further services can be assigned or unassigned""" + +permissions = [value for key, value in globals().items() if not key.startswith("_")] diff --git a/backend/flaschengeist_events/routes.py b/backend/flaschengeist_events/routes.py new file mode 100644 index 0000000..06256ac --- /dev/null +++ b/backend/flaschengeist_events/routes.py @@ -0,0 +1,528 @@ +from http.client import NO_CONTENT +from flask import request, jsonify +from werkzeug.exceptions import BadRequest, NotFound, Forbidden + +from flaschengeist.models.session import Session +from flaschengeist.controller import userController +from flaschengeist.utils.decorators import login_required +from flaschengeist.utils.datetime import from_iso_format +from flaschengeist.utils.HTTP import get_filter_args, no_content + +from . import event_controller, permissions, EventPlugin + + +def dict_get(self, key, default=None, type=None): + """Same as .get from MultiDict""" + try: + rv = self[key] + except KeyError: + return default + if type is not None: + try: + rv = type(rv) + except ValueError: + rv = default + return rv + + +@EventPlugin.blueprint.route("/events/templates", methods=["GET"]) +@login_required() +def get_templates(current_session): + return jsonify(event_controller.get_templates()) + + +@EventPlugin.blueprint.route("/events/event-types", methods=["GET"]) +@EventPlugin.blueprint.route("/events/event-types/", methods=["GET"]) +@login_required() +def get_event_types(current_session, identifier=None): + """Get EventType(s) + + Route: ``/events/event-types`` | Method: ``GET`` + Route: ``/events/event-types/`` | Method: ``GET`` + + Args: + current_session: Session sent with Authorization Header + identifier: If querying a specific EventType + + Returns: + JSON encoded (list of) EventType(s) or HTTP-error + """ + if identifier: + result = event_controller.get_event_type(identifier) + else: + result = event_controller.get_event_types() + return jsonify(result) + + +@EventPlugin.blueprint.route("/events/event-types", methods=["POST"]) +@login_required(permission=permissions.EVENT_TYPE) +def new_event_type(current_session): + """Create a new EventType + + Route: ``/events/event-types`` | Method: ``POST`` + + POST-data: ``{name: string}`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + HTTP-Created or HTTP-error + """ + data = request.get_json() + if "name" not in data: + raise BadRequest + event_type = event_controller.create_event_type(data["name"]) + return jsonify(event_type) + + +@EventPlugin.blueprint.route("/events/event-types/", methods=["PUT", "DELETE"]) +@login_required(permission=permissions.EVENT_TYPE) +def modify_event_type(identifier, current_session): + """Rename or delete an event type + + Route: ``/events/event-types/`` | Method: ``PUT`` or ``DELETE`` + + POST-data: (if renaming) ``{name: string}`` + + Args: + identifier: Identifier of the EventType + current_session: Session sent with Authorization Header + + Returns: + HTTP-NoContent or HTTP-error + """ + if request.method == "DELETE": + event_controller.delete_event_type(identifier) + else: + data = request.get_json() + if "name" not in data: + raise BadRequest("Parameter missing in data") + event_controller.rename_event_type(identifier, data["name"]) + return "", NO_CONTENT + + +@EventPlugin.blueprint.route("/events/job-types", methods=["GET"]) +@login_required() +def get_job_types(current_session): + """Get all JobTypes + + Route: ``/events/job-types`` | Method: ``GET`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + JSON encoded list of JobType HTTP-error + """ + types = event_controller.get_job_types() + return jsonify(types) + + +@EventPlugin.blueprint.route("/events/job-types", methods=["POST"]) +@login_required(permission=permissions.JOB_TYPE) +def new_job_type(current_session): + """Create a new JobType + + Route: ``/events/job-types`` | Method: ``POST`` + + POST-data: ``{name: string}`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + JSON encoded JobType or HTTP-error + """ + data = request.get_json() + if "name" not in data: + raise BadRequest + jt = event_controller.create_job_type(data["name"]) + return jsonify(jt) + + +@EventPlugin.blueprint.route("/events/job-types/", methods=["PUT", "DELETE"]) +@login_required(permission=permissions.JOB_TYPE) +def modify_job_type(type_id, current_session): + """Rename or delete a JobType + + Route: ``/events/job-types/`` | Method: ``PUT`` or ``DELETE`` + + POST-data: (if renaming) ``{name: string}`` + + Args: + type_id: Identifier of the JobType + current_session: Session sent with Authorization Header + + Returns: + HTTP-NoContent or HTTP-error + """ + if request.method == "DELETE": + event_controller.delete_job_type(type_id) + else: + data = request.get_json() + if "name" not in data: + raise BadRequest("Parameter missing in data") + event_controller.rename_job_type(type_id, data["name"]) + return "", NO_CONTENT + + +@EventPlugin.blueprint.route("/events/", methods=["GET"]) +@login_required() +def get_event(event_id, current_session): + """Get event by id + + Route: ``/events/`` | Method: ``GET`` + + Args: + event_id: ID identifying the event + current_session: Session sent with Authorization Header + + Returns: + JSON encoded event object + """ + event = event_controller.get_event( + event_id, + with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP), + ) + return jsonify(event) + + +@EventPlugin.blueprint.route("/events", methods=["GET"]) +@login_required() +def get_events(current_session): + count, result = event_controller.get_events( + *get_filter_args(), + with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP), + ) + return jsonify({"count": count, "result": result}) + + +def _add_job(event, data): + try: + start = from_iso_format(data["start"]) + end = dict_get(data, "end", None, type=from_iso_format) + required_services = data["required_services"] + job_type = data["type"] + if isinstance(job_type, dict): + job_type = data["type"]["id"] + except (KeyError, ValueError): + raise BadRequest("Missing or invalid POST parameter") + + job_type = event_controller.get_job_type(job_type) + event_controller.add_job( + event, + job_type, + required_services, + start, + end, + comment=dict_get(data, "comment", None, str), + ) + + +@EventPlugin.blueprint.route("/events", methods=["POST"]) +@login_required(permission=permissions.CREATE) +def create_event(current_session): + """Create an new event + + Route: ``/events`` | Method: ``POST`` + + POST-data: See interfaces for Event, can already contain jobs + + Args: + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Event object or HTTP-error + """ + data = request.get_json() + try: + start = from_iso_format(data["start"]) + end = dict_get(data, "end", None, type=from_iso_format) + data_type = data["type"] + if isinstance(data_type, dict): + data_type = data["type"]["id"] + event_type = event_controller.get_event_type(data_type) + except KeyError: + raise BadRequest("Missing POST parameter") + except (NotFound, ValueError): + raise BadRequest("Invalid parameter") + + event = event_controller.create_event( + start=start, + end=end, + name=dict_get(data, "name", None), + is_template=dict_get(data, "is_template", None), + event_type=event_type, + description=dict_get(data, "description", None), + ) + if "jobs" in data: + for job in data["jobs"]: + _add_job(event, job) + + return jsonify(event) + + +@EventPlugin.blueprint.route("/events/", methods=["PUT"]) +@login_required(permission=permissions.EDIT) +def modify_event(event_id, current_session): + """Modify an event + + Route: ``/events/`` | Method: ``PUT`` + + POST-data: See interfaces for Event, can already contain slots + + Args: + event_id: Identifier of the event + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Event object or HTTP-error + """ + event = event_controller.get_event(event_id) + data = request.get_json() + event.start = dict_get(data, "start", event.start, type=from_iso_format) + event.end = dict_get(data, "end", event.end, type=from_iso_format) + event.name = dict_get(data, "name", event.name, type=str) + event.description = dict_get(data, "description", event.description, type=str) + if "type" in data: + event_type = event_controller.get_event_type(data["type"]) + event.type = event_type + + event_controller.update() + return jsonify(event) + + +@EventPlugin.blueprint.route("/events/", methods=["DELETE"]) +@login_required(permission=permissions.DELETE) +def delete_event(event_id, current_session): + """Delete an event + + Route: ``/events/`` | Method: ``DELETE`` + + Args: + event_id: Identifier of the event + current_session: Session sent with Authorization Header + + Returns: + HTTP-NoContent or HTTP-error + """ + event_controller.delete_event(event_id) + return "", NO_CONTENT + + +@EventPlugin.blueprint.route("/events//jobs", methods=["POST"]) +@login_required(permission=permissions.EDIT) +def add_job(event_id, current_session): + """Add an new Job to an Event / EventSlot + + Route: ``/events//jobs`` | Method: ``POST`` + + POST-data: See Job + + Args: + event_id: Identifier of the event + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Event object or HTTP-error + """ + event = event_controller.get_event(event_id) + _add_job(event, request.get_json()) + return jsonify(event) + + +@EventPlugin.blueprint.route("/events//jobs/", methods=["DELETE"]) +@login_required(permission=permissions.DELETE) +def delete_job(event_id, job_id, current_session): + """Delete a Job + + Route: ``/events//jobs/`` | Method: ``DELETE`` + + Args: + event_id: Identifier of the event + job_id: Identifier of the Job + current_session: Session sent with Authorization Header + + Returns: + HTTP-no-content or HTTP error + """ + job = event_controller.get_job(job_id, event_id) + event_controller.delete_job(job) + return no_content() + + +@EventPlugin.blueprint.route("/events//jobs/", methods=["PUT"]) +@login_required() +def update_job(event_id, job_id, current_session: Session): + """Edit Job + + Route: ``/events//jobs/`` | Method: ``PUT`` + + POST-data: See TS interface for Job + + Args: + event_id: Identifier of the event + job_id: Identifier of the Job + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Job object or HTTP-error + """ + if not current_session.user_.has_permission(permissions.EDIT): + raise Forbidden + + data = request.get_json() + if not data: + raise BadRequest + + job = event_controller.get_job(job_id, event_id) + try: + if "type" in data: + job.type = event_controller.get_job_type(data["type"]) + job.start = from_iso_format(data.get("start", job.start)) + job.end = from_iso_format(data.get("end", job.end)) + job.comment = str(data.get("comment", job.comment)) + job.locked = bool(data.get("locked", job.locked)) + job.required_services = float(data.get("required_services", job.required_services)) + event_controller.update() + except NotFound: + raise BadRequest("Invalid JobType") + except ValueError: + raise BadRequest("Invalid POST data") + + return jsonify(job) + + +@EventPlugin.blueprint.route("/events/jobs", methods=["GET"]) +@login_required() +def get_jobs(current_session: Session): + count, result = event_controller.get_jobs(current_session.user_, *get_filter_args()) + return jsonify({"count": count, "result": result}) + + +@EventPlugin.blueprint.route("/events/jobs//assign", methods=["POST"]) +@login_required() +def assign_job(job_id, current_session: Session): + """Assign / unassign user to the Job + + Route: ``/events/jobs//assign`` | Method: ``POST`` + + POST-data: a Service object, see TS interface for Service + + Args: + job_id: Identifier of the Job + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Job or HTTP-error + """ + data = request.get_json() + job = event_controller.get_job(job_id) + try: + user = userController.get_user(data["userid"]) + value = data["value"] + if (user == current_session.user_ and not user.has_permission(permissions.ASSIGN)) or ( + user != current_session.user_ and not current_session.user_.has_permission(permissions.ASSIGN_OTHER) + ): + raise Forbidden + if value > 0: + event_controller.assign_job(job, user, value, data.get("is_backup", False)) + else: + event_controller.unassign_job(job, user, notify=user != current_session.user_) + except (TypeError, KeyError, ValueError): + raise BadRequest + return jsonify(job) + + +@EventPlugin.blueprint.route("/events/jobs//lock", methods=["POST"]) +@login_required(permissions.LOCK_JOBS) +def lock_job(job_id, current_session: Session): + """Lock / unlock the Job + + Route: ``/events/jobs//lock`` | Method: ``POST`` + + POST-data: ``{locked: boolean}`` + + Args: + job_id: Identifier of the Job + current_session: Session sent with Authorization Header + + Returns: + HTTP-No-Content or HTTP-error + """ + data = request.get_json() + job = event_controller.get_job(job_id) + try: + locked = bool(userController.get_user(data["locked"])) + job.locked = locked + event_controller.update() + except (TypeError, KeyError, ValueError): + raise BadRequest + return no_content() + + +@EventPlugin.blueprint.route("/events/invitations", methods=["POST"]) +@login_required() +def invite(current_session: Session): + """Invite an user to a job or transfer job + + Route: ``/events/invites`` | Method: ``POST`` + + POST-data: ``{job: number, invitees: string[], is_transfer?: boolean}`` + + Args: + current_session: Session sent with Authorization Header + + Returns: + List of Invitation objects or HTTP-error + """ + data = request.get_json() + transferee = data.get("transferee", None) + if ( + transferee is not None + and transferee != current_session.userid + and not current_session.user_.has_permission(permissions.ASSIGN_OTHER) + ): + raise Forbidden + + try: + job = event_controller.get_job(data["job"]) + if not isinstance(data["invitees"], list): + raise BadRequest + return jsonify( + [ + event_controller.invite(job, invitee, current_session.user_, transferee) + for invitee in [userController.get_user(uid) for uid in data["invitees"]] + ] + ) + except (TypeError, KeyError, ValueError): + raise BadRequest + + +@EventPlugin.blueprint.route("/events/invitations/", methods=["GET"]) +@login_required() +def get_invitation(invitation_id: int, current_session: Session): + inv = event_controller.get_invitation(invitation_id) + if current_session.userid not in [inv.invitee_id, inv.inviter_id, inv.transferee_id]: + raise Forbidden + return jsonify(inv) + + +@EventPlugin.blueprint.route("/events/invitations/", methods=["DELETE", "PUT"]) +@login_required() +def respond_invitation(invitation_id: int, current_session: Session): + inv = event_controller.get_invitation(invitation_id) + if request.method == "DELETE": + if current_session.userid == inv.invitee_id: + event_controller.respond_invitation(inv, False) + elif current_session.userid == inv.inviter_id: + event_controller.cancel_invitation(inv) + else: + raise Forbidden + else: + # maybe validate data is something like ({accepted: true}) + if current_session.userid != inv.invitee_id: + raise Forbidden + event_controller.respond_invitation(inv) + return no_content() diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..07de284 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/backend/setup.cfg b/backend/setup.cfg new file mode 100644 index 0000000..5f5540b --- /dev/null +++ b/backend/setup.cfg @@ -0,0 +1,30 @@ +[metadata] +license = MIT +version = 0.0.1-dev.1 +name = flaschengeist-events +description = Events plugin for Flaschengeist +url = https://flaschengeist.dev/Flaschengeist/flaschengeist-schedule +project_urls = + Source = https://flaschengeist.dev/Flaschengeist/flaschengeist-schedule + Tracker = https://flaschengeist.dev/Flaschengeist/flaschengeist-schedule/issues +classifiers = + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.8, + Programming Language :: Python :: 3.9, + Programming Language :: Python :: 3.10, + License :: OSI Approved :: MIT License + Operating System :: OS Independent + + +[bdist_wheel] +universal = True + +[options] +packages = + flaschengeist_events +install_requires = + flaschengeist == 2.0.* + +[options.entry_points] + flaschengeist.plugins = + events = flaschengeist_events:EventPlugin