diff --git a/flaschengeist/plugins/events/event_controller.py b/flaschengeist/plugins/events/event_controller.py index 6a3f9f2..4cd2d03 100644 --- a/flaschengeist/plugins/events/event_controller.py +++ b/flaschengeist/plugins/events/event_controller.py @@ -1,16 +1,32 @@ from datetime import datetime, timedelta, timezone +from enum import IntEnum from typing import Optional 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.events import EventPlugin -from flaschengeist.plugins.events.models import EventType, Event, Job, JobType, Service +from flaschengeist.plugins.events.models import EventType, Event, Invitation, Job, JobType, Service from flaschengeist.utils.scheduler import scheduled +# 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() @@ -232,6 +248,8 @@ def update(): 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() @@ -242,8 +260,7 @@ def assign_job(job: Job, user, value, is_backup=False): if service: service.value = value else: - service = Service(user_=user, value=value, is_backup=is_backup, job_=job) - db.session.add(service) + job.services.append(Service(user_=user, value=value, is_backup=is_backup, job_=job)) db.session.commit() @@ -264,6 +281,52 @@ def unassign_job(job: Job = None, user=None, service=None, notify=False): 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") diff --git a/flaschengeist/plugins/events/models.py b/flaschengeist/plugins/events/models.py index 2cbfd6e..36fd3f9 100644 --- a/flaschengeist/plugins/events/models.py +++ b/flaschengeist/plugins/events/models.py @@ -73,6 +73,7 @@ class Job(db.Model, ModelSerializeMixin): event_ = db.relationship("Event", back_populates="jobs") event_id_ = db.Column("event_id", Serial, db.ForeignKey(f"{_table_prefix_}event.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"),) @@ -106,25 +107,33 @@ class Event(db.Model, ModelSerializeMixin): ) -class Invite(db.Model, ModelSerializeMixin): - __tablename__ = _table_prefix_ + "invite" +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 - sender_id: str = None + 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 - invitee_: User = db.relationship("User", foreign_keys="Invite._invitee_id") - sender_: User = db.relationship("User", foreign_keys="Invite._sender_id") + 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) - _sender_id = db.Column("sender_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 sender_id(self): - return self.sender_.userid + 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/flaschengeist/plugins/events/routes.py b/flaschengeist/plugins/events/routes.py index 2771c72..2fb9b60 100644 --- a/flaschengeist/plugins/events/routes.py +++ b/flaschengeist/plugins/events/routes.py @@ -463,7 +463,7 @@ def assign_job(job_id, current_session: Session): current_session: Session sent with Authorization Header Returns: - HTTP-No-Content or HTTP-error + JSON encoded Job or HTTP-error """ data = request.get_json() job = event_controller.get_job(job_id) @@ -480,7 +480,7 @@ def assign_job(job_id, current_session: Session): event_controller.unassign_job(job, user, notify=user != current_session.user_) except (TypeError, KeyError, ValueError): raise BadRequest - return no_content() + return jsonify(job) @EventPlugin.blueprint.route("/events/jobs//lock", methods=["POST"]) @@ -510,4 +510,67 @@ def lock_job(job_id, current_session: Session): return no_content() -# TODO: JobTransfer +@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()