diff --git a/flaschengeist/plugins/schedule/__init__.py b/flaschengeist/plugins/schedule/__init__.py index 7c539c4..fb5e4a5 100644 --- a/flaschengeist/plugins/schedule/__init__.py +++ b/flaschengeist/plugins/schedule/__init__.py @@ -2,24 +2,25 @@ Provides duty schedule / duty roster functions """ -import http - -from dateutil import parser -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone +from http.client import NO_CONTENT, CREATED from flask import Blueprint, request, jsonify from werkzeug.exceptions import BadRequest, NotFound +from backports.datetime_fromisoformat import MonkeyPatch -from flaschengeist.database import db from flaschengeist.plugins import Plugin from flaschengeist.decorator import login_required from . import event_controller, permissions -from .models import EventType +from . import models +MonkeyPatch.patch_fromisoformat() schedule_bp = Blueprint("schedule", __name__, url_prefix="/schedule") class SchedulePlugin(Plugin): + models = models + def __init__(self, config): super().__init__( blueprint=schedule_bp, @@ -63,7 +64,7 @@ def new_event_type(current_session): if "name" not in data: raise BadRequest event_controller.create_event_type(data["name"]) - return "", http.HTTPStatus.CREATED + return "", CREATED @schedule_bp.route("/event-types/", methods=["PUT", "DELETE"]) @@ -89,7 +90,7 @@ def modify_event_type(name, current_session): if "name" not in data: raise BadRequest("Parameter missing in data") event_controller.rename_event_type(name, data["name"]) - return "", http.HTTPStatus.NO_CONTENT + return "", NO_CONTENT @schedule_bp.route("/job-types", methods=["GET"]) @@ -128,7 +129,7 @@ def new_job_type(current_session): if "name" not in data: raise BadRequest event_controller.create_job_type(data["name"]) - return "", http.HTTPStatus.CREATED + return "", CREATED @schedule_bp.route("/job-types/", methods=["PUT", "DELETE"]) @@ -154,10 +155,9 @@ def modify_job_type(name, current_session): if "name" not in data: raise BadRequest("Parameter missing in data") event_controller.rename_name_type(name, data["name"]) - return "", http.HTTPStatus.NO_CONTENT + return "", NO_CONTENT -########### TODO: Ab hier ############ @schedule_bp.route("/events/", methods=["GET"]) @login_required() def get_event(event_id, current_session): @@ -173,8 +173,6 @@ def get_event(event_id, current_session): JSON encoded event object """ event = event_controller.get_event(event_id) - if not event: - raise NotFound return jsonify(event) @@ -196,9 +194,6 @@ def get_events(current_session, year=datetime.now().year, month=datetime.now().m Returns: JSON encoded list containing events found or HTTP-error - - Raises: - BadRequest: If date is invalid """ try: begin = datetime(year=year, month=month, day=1) @@ -217,88 +212,169 @@ def get_events(current_session, year=datetime.now().year, month=datetime.now().m raise BadRequest("Invalid date given") +def _event_slot_from_data(data): + try: + start = datetime.fromisoformat(data["start"]).replace(tzinfo=timezone.utc) + end = datetime.fromisoformat(data["end"]).replace(tzinfo=timezone.utc) if "end" in data else None + # jobs = ... + except (NotFound, KeyError, ValueError): + raise BadRequest("Missing POST parameter") + return {"start": start, "end": end} + + @schedule_bp.route("/events", methods=["POST"]) @login_required(permission=permissions.CREATE) -def __new_event(**kwargs): +def create_event(current_session): + """Create an new event + + Route: ``/events`` | Method: ``POST`` + + POST-data: See interfaces for Event, can already contain slots + + Args: + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Event object or HTTP-error + """ + data = request.get_json() + try: + start = datetime.fromisoformat(data["start"]).replace(tzinfo=timezone.utc) + event_type = event_controller.get_event_type(data["type"]) + except (NotFound, KeyError, ValueError): + raise BadRequest("Missing POST parameter") + data = request.get_json() event = event_controller.create_event( - begin=parser.isoparse(data["begin"]), - end=parser.isoparse(data["end"]), - description=data["description"], - type=EventType.query.get(data["kind"]), + start=start, event_type=event_type, description=data["description"] if "description" in data else None ) - return jsonify({"ok": "ok", "id": event.id}) + if "slots" in data: + for slot in data["slots"]: + event_controller.add_event_slot(event, **_event_slot_from_data(slot)) + return jsonify(event) + + +@schedule_bp.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() + if "start" in data: + event.start = datetime.fromisoformat(data["start"]).replace(tzinfo=timezone.utc) + if "description" in data: + event.description = data["description"] + if "type" in data: + event_type = event_controller.get_event_type(data["type"]) + event.type = event_type + event_controller.update() + return jsonify(event) @schedule_bp.route("/events/", methods=["DELETE"]) @login_required(permission=permissions.DELETE) -def __delete_event(event_id, **kwargs): - if not event_controller.delete_event(event_id): - raise NotFound - db.session.commit() - return jsonify({"ok": "ok"}) +def delete_event(event_id, current_session): + """Delete an event + Route: ``/events/`` | Method: ``DELETE`` -@schedule_bp.route("/events//slots", methods=["GET"]) -@login_required() -def __get_slots(event_id, **kwargs): - event = event_controller.get_event(event_id) - if not event: - raise NotFound - return jsonify({event.slots}) + Args: + event_id: Identifier of the event + current_session: Session sent with Authorization Header - -@schedule_bp.route("/events//slots/", methods=["GET"]) -@login_required() -def __get_slot(event_id, slot_id, **kwargs): - slot = event_controller.get_event_slot(slot_id, event_id) - if slot: - return jsonify(slot) - raise NotFound - - -@schedule_bp.route("/events//slots/", methods=["DELETE"]) -@login_required(permission=permissions.DELETE) -def __delete_slot(event_id, slot_id, **kwargs): - if event_controller.delete_event_slot(slot_id, event_id): - return jsonify({"ok": "ok"}) - raise NotFound - - -@schedule_bp.route("/events//slots/", methods=["PUT"]) -@login_required(permission=permissions.EDIT) -def __update_slot(event_id, slot_id, **kwargs): - data = request.get_json() - if not data: - raise BadRequest - - for job in data["jobs"]: - event_controller.add_job(job.kind, job.user) - if event_controller.delete_event_slot(slot_id, event_id): - return jsonify({"ok": "ok"}) - raise NotFound + Returns: + HTTP-NoContent or HTTP-error + """ + event_controller.delete_event(event_id) + return "", NO_CONTENT @schedule_bp.route("/events//slots", methods=["POST"]) @login_required(permission=permissions.EDIT) -def __add_slot(event_id, **kwargs): +def add_event_slot(event_id, current_session): + """Add an new EventSlot to an Event + + Route: ``/events//slots`` | Method: ``POST`` + + POST-data: See TS interface for EventSlot + + 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) - if not event: - raise NotFound data = request.get_json() - attr = {"job_slots": []} - try: - if "start" in data: - attr["start"] = parser.isoparse(data["start"]) - if "end" in data: - attr["end"] = parser.isoparse(data["end"]) - for job in data["jobs"]: - attr["job_slots"].append({"needed_persons": job["needed_persons"], "kind": job["kind"]}) - except KeyError: - raise BadRequest("Missing data in request") - event_controller.add_slot(event, **attr) - return jsonify({"ok": "ok"}) + if not data: + raise BadRequest("Missing POST parameters") + + event_controller.add_event_slot(event, **_event_slot_from_data(data)) + return jsonify(event) -def __edit_event(): - ... +@schedule_bp.route("/events//slots/", methods=["PUT"]) +@login_required(permission=permissions.EDIT) +def update_event_slot(event_id, slot_id, current_session): + """Update an EventSlot + + Route: ``/events//slots/`` | Method: ``PUT`` + + POST-data: See TS interface for EventSlot + + Args: + event_id: Identifier of the event + slot_id: Identifier of the slot + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Event object or HTTP-error + """ + event = event_controller.get_event(event_id) + slot = event_controller.get_event_slot(slot_id) + if slot not in event.slots: + raise NotFound + + data = _event_slot_from_data(request.get_json()) + slot.start = data["start"] + if "end" in data: + slot.end = data["end"] + event_controller.update() + return jsonify(event) + + +@schedule_bp.route("/events//slots/", methods=["DELETE"]) +@login_required(permission=permissions.EDIT) +def delete_event_slot(event_id, slot_id, current_session): + """Delete an EventSlot + + Route: ``/events//slots/`` | Method: ``DELETE`` + + Args: + event_id: Identifier of the event + slot_id: Identifier of the slot + current_session: Session sent with Authorization Header + + Returns: + JSON encoded Event object or HTTP-error + """ + event = event_controller.get_event(event_id) + event_controller.remove_event_slot(event, slot_id) + return jsonify(event) + + +# TODO: JobSlot, Job! +# TODO: JobTransfer diff --git a/flaschengeist/plugins/schedule/event_controller.py b/flaschengeist/plugins/schedule/event_controller.py index 8436285..2ae486c 100644 --- a/flaschengeist/plugins/schedule/event_controller.py +++ b/flaschengeist/plugins/schedule/event_controller.py @@ -6,6 +6,10 @@ from flaschengeist.database import db from flaschengeist.plugins.schedule.models import EventType, Event, EventSlot, JobSlot, JobType +def update(): + db.session.commit() + + def get_event_types(): return EventType.query.all() @@ -84,34 +88,41 @@ def delete_job_type(name): raise BadRequest("Type still in use") -def get_event(id): - return Event.query.get(id) +def get_event(event_id): + event = Event.query.get(event_id) + if event is None: + raise NotFound + return event -def get_events(begin, end): +def get_events(start, end): """Query events which start from begin until end Args: - begin (datetime): Earliest start + start (datetime): Earliest start end (datetime): Latest start Returns: collection of Event objects """ - return Event.query.filter((begin <= Event.begin), (Event.begin < end)) + return Event.query.filter((start <= Event.start), (Event.start < end)).all() -def delete_event(id): +def delete_event(event_id): """Delete event with given ID Args: - id: id of Event to delete + event_id: id of Event to delete - Returns: True if successful, False if Event is not found + Raises: + NotFound if not found """ - return Event.query.filter(Event.id == id).delete() == 1 + event = get_event(event_id) + db.session.delete(event) + db.session.commit() -def create_event(begin, kind, end=None, description=None): +def create_event(event_type, start, slots=[], description=None): try: - event = Event(begin=begin, end=end, description=description, kind=kind) + logger.debug(event_type) + event = Event(start=start, description=description, type=event_type, slots=slots) db.session.add(event) db.session.commit() return event @@ -120,24 +131,25 @@ def create_event(begin, kind, end=None, description=None): raise BadRequest -def create_job_kind(name): - try: - kind = JobKind(name=name) - db.session.add(kind) - db.session.commit() - return kind - except IntegrityError: - logger.debug("IntegrityError: Looks like there is a name collision", exc_info=True) - raise BadRequest("Name already exists") +def get_event_slot(slot_id): + slot = EventSlot.query.get(slot_id) + if slot is None: + raise NotFound + return slot - - -def add_slot(event, job_slots, needed_persons, start=None, end=None): - event_slot = EventSlot(start=start, end=end) - for slot in job_slots: - kind = JobKind.query.get(slot.id) - job_slot = JobSlot(kind=kind, needed_persons=slot.needed_persons) - event_slot.add_slot(job_slot) - event.add_slot(event_slot) +def add_event_slot(event, start, end=None): + event_slot = EventSlot(start=start, end=end, event_=event) + if start < event.start: + raise BadRequest("Start before event start") + db.session.add(event_slot) + db.session.commit() + + +def remove_event_slot(event, slot_id): + slot = get_event_slot(slot_id) + if slot in event.slots: + event.slots.remove(slot) + else: + raise NotFound db.session.commit() diff --git a/flaschengeist/plugins/schedule/models.py b/flaschengeist/plugins/schedule/models.py index 4ab1455..8122087 100644 --- a/flaschengeist/plugins/schedule/models.py +++ b/flaschengeist/plugins/schedule/models.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Optional -from flaschengeist.models import ModelSerializeMixin +from flaschengeist.models import ModelSerializeMixin, UtcDateTime from flaschengeist.models.user import User from flaschengeist.database import db @@ -21,6 +21,7 @@ class JobType(db.Model, ModelSerializeMixin): id_ = db.Column("id", db.Integer, primary_key=True) name: str = db.Column(db.String(30), nullable=False, unique=True) + ######## # Jobs # ######## @@ -55,6 +56,7 @@ class JobSlot(db.Model, ModelSerializeMixin): event_slot_ = db.relationship("EventSlot", back_populates="jobs") + ########## # Events # ########## @@ -62,12 +64,13 @@ class JobSlot(db.Model, ModelSerializeMixin): class EventSlot(db.Model, ModelSerializeMixin): """Model for an EventSlot""" + __tablename__ = "event_slot" _event_id = db.Column("event_id", db.Integer, db.ForeignKey("event.id"), nullable=False) id: int = db.Column(db.Integer, primary_key=True) - start: datetime = db.Column(db.DateTime) - end: Optional[datetime] = db.Column(db.DateTime) + start: datetime = db.Column(UtcDateTime) + end: Optional[datetime] = db.Column(UtcDateTime) jobs: [JobSlot] = db.relationship("JobSlot", back_populates="event_slot_") event_ = db.relationship("Event", back_populates="slots") @@ -75,12 +78,14 @@ class EventSlot(db.Model, ModelSerializeMixin): class Event(db.Model, ModelSerializeMixin): """Model for an Event""" + __tablename__ = "event" _type_id = db.Column("type_id", db.Integer, db.ForeignKey("event_type.id", ondelete="CASCADE"), nullable=False) id: int = db.Column(db.Integer, primary_key=True) - begin: datetime = db.Column(db.DateTime, nullable=False) - end: datetime = db.Column(db.DateTime) - description: str = db.Column(db.String(240)) + start: datetime = db.Column(UtcDateTime, nullable=False) + description: Optional[str] = db.Column(db.String(240)) type: EventType = db.relationship("EventType") - slots: [EventSlot] = db.relationship("EventSlot", back_populates="event_", cascade="all, delete") + slots: [EventSlot] = db.relationship( + "EventSlot", back_populates="event_", cascade="all,delete,delete-orphan", order_by="EventSlot.start" + ) diff --git a/setup.py b/setup.py index c6c3874..527dca7 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,6 @@ setup( "flask_sqlalchemy", "flask_cors", "werkzeug", - "python-dateutil", # Needed for python < 3.7 "backports-datetime-fromisoformat", ],