Compare commits

...

65 Commits

Author SHA1 Message Date
Tim Gröger 6737bd5b45 update to version 1.1.0 2024-10-08 14:53:22 +02:00
Tim Gröger aa3c172160 [feat] show names by display mode setting 2024-10-08 14:46:44 +02:00
Tim Gröger 923f5ec27c update version to 1.0.0 2024-01-16 19:54:07 +01:00
Tim Gröger 084ad8f945 fix correct datetimes in jobslots when change event start 2024-01-16 15:55:14 +01:00
Tim Gröger 7b1e37b3a7 fix correct datetimes in jobslots when come from template 2024-01-16 15:52:23 +01:00
Tim Gröger 9a648e8443 fix show january 2024-01-16 14:58:34 +01:00
Tim Gröger 38480a1eec update to v1.0.0-alpha.9 2023-05-16 00:04:42 +02:00
Tim Gröger f0e07138b1 fixed intput theming 2023-05-15 10:32:31 +02:00
Tim Gröger ba7013ac67 add show sended invitation to query 2023-05-13 00:06:27 +02:00
Tim Gröger 252072df57 (fix) #1 in frontend
required services have to be >= 1
2023-05-12 23:37:20 +02:00
Tim Gröger c5012c8431 update dependencies, new version 2023-05-08 21:40:52 +02:00
Tim Gröger 40148ba10f update version 2023-05-05 14:48:52 +02:00
Tim Gröger 3fbbacabca check has permission assign 2023-05-03 06:11:52 +02:00
Tim Gröger 6f6fca84ed fix load older events in listView 2023-05-03 00:19:09 +02:00
Tim Gröger b5d43bb1de fix editing ande remove events in listview 2023-05-03 00:09:21 +02:00
Tim Gröger 7ea5a8fac3 duty fix for listView in Overview 2023-05-02 23:17:45 +02:00
Tim Gröger 67d844d6e6 add on every page query for location 2023-05-02 23:15:32 +02:00
Tim Gröger 93f88792d0 update job start and end time when event time change 2023-05-02 22:40:54 +02:00
Tim Gröger 6ad340fe7c add query on agendaView 2023-05-02 15:00:39 +02:00
Tim Gröger c767d92442 add notification 2023-05-02 06:40:48 +02:00
Tim Gröger 8cd9182a8b remove invitation when reject or accept 2023-05-02 06:23:36 +02:00
Tim Gröger 46939d4b64 fix typing 2023-05-01 22:47:37 +02:00
Tim Gröger ae275aeabb remove unecessary notification in frontend 2023-05-01 22:46:16 +02:00
Tim Gröger c721f25104 delete unnecessary notifications 2023-05-01 22:06:24 +02:00
Tim Gröger 26235fef49 add actions on events-request-page 2023-05-01 11:00:24 +02:00
Tim Gröger b33d30fe40 black 2023-04-30 13:56:14 +02:00
Tim Gröger 28223d12b6 fix transferjob when accepted 2023-04-30 13:55:57 +02:00
Tim Gröger 0f0e6702e2 prettier 2023-04-30 13:55:17 +02:00
Tim Gröger b4c3cfa365 fix and add more notification 2023-04-30 13:55:00 +02:00
Tim Gröger 941841b1bb change id to get notifications 2023-04-30 09:49:51 +02:00
Tim Gröger 31c6410eba change view of listview 2023-04-28 20:45:47 +02:00
Tim Gröger 144bc1d58e load jobTypes before used 2023-04-28 20:45:30 +02:00
Tim Gröger a88e3a0160 remove card from EventRequests 2023-04-28 20:05:15 +02:00
Tim Gröger 2ddb89f89c add IDE options in gitignore 2023-04-28 19:56:58 +02:00
Tim Gröger 3ba7e5d366 add end time for job, delete empty jobtype 2023-04-28 19:55:12 +02:00
Ferdinand Thiessen e1ad8f0f11 feat(ui): Implemented job invitations / transfer page 2023-04-28 13:52:32 +02:00
Tim Gröger eb0e54714b fix add Jobs backend 2023-04-28 13:51:41 +02:00
Tim Gröger 994f65c38b fix add jobs 2023-04-28 13:51:11 +02:00
Tim Gröger b7741cfa37 fix adding, modify, remove jobs from event 2023-04-26 11:28:16 +02:00
Tim Gröger 9359db3881 fix add jobs 2023-04-24 21:14:19 +02:00
Tim Gröger f54911ed48 add alembic migration; delete unneccessary card layout 2023-04-10 14:55:17 +02:00
Tim Gröger 7923f4889c fix depracation warnings (sqlalchemy 2.0), fix installable plugin 2023-04-09 23:41:10 +02:00
Ferdinand Thiessen 75fa825962 fix(backend): Provide version, fix metadata
continuous-integration/woodpecker the build was successful Details
2021-12-23 03:24:12 +01:00
Ferdinand Thiessen 166073fb55 fix(ui): Fix routes, requests should have more priority than settings
continuous-integration/woodpecker the build was successful Details
2021-12-22 01:09:39 +01:00
Ferdinand Thiessen 0f65ae53af feat(ui): Add functions for getting a job and invitations to the events store 2021-12-22 01:08:48 +01:00
Ferdinand Thiessen 2ef9fd023a fix(ui): Align api with backend 2021-12-22 01:07:45 +01:00
Ferdinand Thiessen a09ce26474 feat(backend): Add routed for get_job and get_invitations 2021-12-22 01:06:23 +01:00
Ferdinand Thiessen 5a52c364e4 chore(deps): Set minimum supported python version to 3.8. 2021-12-22 01:05:42 +01:00
Ferdinand Thiessen 72cb163a00 fix(backend): Fix Job model and using controller functions
type should be the type id not the object
2021-12-22 01:05:10 +01:00
Ferdinand Thiessen 62599898d0 fix(backend): Invite must search user in database
continuous-integration/woodpecker the build was successful Details
2021-12-17 14:50:42 +01:00
Ferdinand Thiessen 90219c5ddd chore(ci): Rename pipelines to match other projects 2021-12-17 14:48:53 +01:00
Ferdinand Thiessen c2e0e55a19 fix(ui): Invite should send only transferee userid 2021-12-17 14:48:20 +01:00
Ferdinand Thiessen eed25f8290 chore(deps): Update dependencies + tag new version
continuous-integration/woodpecker the build was successful Details
2021-12-13 20:18:19 +01:00
Ferdinand Thiessen e733ee0f40 feat(ci): Added woodpecker CI 2021-12-13 20:18:16 +01:00
Ferdinand Thiessen d200370975 fix(backend): Use new scheduler plugin for assign_backups 2021-12-06 23:49:27 +01:00
Ferdinand Thiessen 960a19dae8 chore(ui): Minor cleanup 2021-12-06 13:15:34 +01:00
Ferdinand Thiessen e4d3ef2097 chore(ui): Update dependencies 2021-12-06 00:24:08 +01:00
Ferdinand Thiessen dd49b0eb9e feat(ui): Allow assigning other users (if you have the permission). 2021-12-06 00:23:15 +01:00
Ferdinand Thiessen 58621d3da4 chore(ui): Fix code formatting 2021-12-06 00:23:10 +01:00
Ferdinand Thiessen 11a4f87005 fix(backend): Allow unassigning deleted users, but not assigning 2021-12-05 22:59:13 +01:00
Ferdinand Thiessen 32bcf7ecbf fix(backend): Fix changing service type between backup and normal 2021-12-02 21:31:20 +01:00
Ferdinand Thiessen 7baa274d02 feat(backend): Remove deleted user from all futur services 2021-12-02 21:30:42 +01:00
Ferdinand Thiessen 81c33ece8c fix(ui) Minor usability fixes for transfer / invite dialog 2021-11-29 11:32:20 +01:00
Ferdinand Thiessen de6e959937 chore(backend) Split backend from flaschengeist, now developed here 2021-11-28 22:30:15 +01:00
Ferdinand Thiessen c8ae458775 fix(ui) AgendaView now shows correct events on small devices 2021-11-28 22:21:07 +01:00
34 changed files with 2484 additions and 296 deletions

View File

@ -17,11 +17,11 @@ module.exports = {
project: resolve(__dirname, './tsconfig.json'),
tsconfigRootDir: __dirname,
ecmaVersion: 2019, // Allows for the parsing of modern ECMAScript features
sourceType: 'module' // Allows for the use of imports
sourceType: 'module', // Allows for the use of imports
},
env: {
browser: true
browser: true,
},
// Rules order is important, please avoid shuffling them
@ -44,7 +44,7 @@ module.exports = {
// https://github.com/prettier/eslint-config-prettier#installation
// usage with Prettier, provided by 'eslint-config-prettier'.
'prettier', //'plugin:prettier/recommended'
'plugin:prettier/recommended',
],
plugins: [
@ -54,10 +54,6 @@ module.exports = {
// https://eslint.vuejs.org/user-guide/#why-doesn-t-it-work-on-vue-file
// required to lint *.vue files
'vue',
// https://github.com/typescript-eslint/typescript-eslint/issues/389#issuecomment-509292674
// Prettier has not been included as plugin to avoid performance impact
// add it as an extension for your IDE
],
// add your custom rules here
@ -66,10 +62,8 @@ module.exports = {
// TypeScript
quotes: ['warn', 'single', { avoidEscape: true }],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
// allow debugger during development only
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
}
}
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
},
};

8
.gitignore vendored
View File

@ -3,3 +3,11 @@ node_modules/
yarn-error.log
# No need, this is done by user
yarn.lock
# Backend
*.egg-info
__pycache__
# IDE
.idea
*.swp

5
.npmignore Normal file
View File

@ -0,0 +1,5 @@
yarn-error.log
.woodpecker.yml
backend/

14
.woodpecker/deploy.yml Normal file
View File

@ -0,0 +1,14 @@
pipeline:
deploy:
when:
event: tag
tag: v*
image: node:lts-alpine
commands:
- echo "//registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN" > .npmrc
- yarn publish --non-interactive
secrets: [ node_auth_token ]
depends_on:
- lint

9
.woodpecker/lint.yml Normal file
View File

@ -0,0 +1,9 @@
pipeline:
lint:
when:
branch: [main, develop]
image: node:lts-alpine
commands:
- yarn install
- yarn lint

View File

@ -1,4 +1,5 @@
# Flaschengeist `schedule` fontend-plugin
![status-badge](https://ci.os-sc.org/api/badges/Flaschengeist/flaschengeist-schedule/status.svg)
This package provides the [Flaschengeist](https://flaschengeist.dev/Flaschengeist/flaschengeist) frontend for the schedule plugin (event and schedule management).

View File

@ -0,0 +1,37 @@
"""Events plugin
Provides duty schedule / duty roster functions
"""
import pkg_resources
from flask import Blueprint, current_app
from werkzeug.local import LocalProxy
from flaschengeist.plugins import Plugin
from . import permissions, models
__version__ = pkg_resources.get_distribution("flaschengeist_events").version
class EventPlugin(Plugin):
# id = "dev.flaschengeist.events"
# provided resources
# permissions = permissions.permissions
models = models
# def __init__(self, cfg):
# super(EventPlugin, self).__init__(cfg)
# from . import routes
# from .event_controller import clear_services
def load(self):
from .routes import blueprint
self.blueprint = blueprint
def install(self):
self.install_permissions(permissions.permissions)
@staticmethod
def getPlugin() -> LocalProxy["EventPlugin"]:
return LocalProxy(lambda: current_app.config["FG_PLUGINS"]["events"])

View File

@ -0,0 +1,450 @@
from datetime import datetime, timedelta, timezone
from enum import IntEnum
from typing import Optional, Tuple, Union
from flaschengeist.controller import userController
from flaschengeist.models import Notification, 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 = 0x11
INVITATION_REJECTED = 0x12
# Information responses 0x20..0x2F
INFO_ACCEPTED = 0x21
INFO_REJECTED = 0x22
@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 = db.select(Job).where(Job.id == job_id)
if event_id is not None:
query = query.where(Job.event_id_ == event_id)
job = db.session.execute(query).scalar_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, notify=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))
if notify:
EventPlugin.getPlugin().notify(
user,
f"You were assigned to a job\n{job.start.strftime('%d.%m.%Y')}",
{"type": NotifyType.INFO_ACCEPTED, "event_id": job.event_id_},
)
db.session.commit()
def unassign_job(job: Job = None, user=None, service=None, notify=False):
_date = job.start.strftime("%d.%m.%Y")
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.getPlugin().notify(
user, f"Your assignmet was cancelled\n{_date}", {"type": NotifyType.INFO_REJECTED, "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()
_date = job.start.strftime("%d.%m.%Y")
if transferee is None:
EventPlugin.getPlugin().notify(
invitee, _(f"Job invitation\n{_date}"), {"type": NotifyType.INVITE, "invitation": inv.id}
)
else:
EventPlugin.getPlugin().notify(
invitee, _(f"Job transfer\n{_date}"), {"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 cleanup_notifications(inv: Invitation):
notifications = tuple(
filter(
lambda notification: notification.data.get("invitation") == inv.id, EventPlugin.getPlugin().notifications
)
)
for notification in notifications:
db.session.delete(notification)
db.session.commit()
def cancel_invitation(inv: Invitation):
db.session.delete(inv)
db.session.commit()
cleanup_notifications(inv)
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.getPlugin().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 = tuple(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.getPlugin().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.getPlugin().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.getPlugin().notify(
service.user_,
"Your backup assignment was accepted.",
{"event_id": service.job_.event_id_},
)
db.session.commit()

View File

@ -0,0 +1,105 @@
"""init events
Revision ID: e70508bd8cb4
Revises: 20482a003db8
Create Date: 2023-04-10 14:21:47.007251
"""
from alembic import op
import sqlalchemy as sa
import flaschengeist
# revision identifiers, used by Alembic.
revision = "e70508bd8cb4"
down_revision = None
branch_labels = ("events",)
depends_on = "flaschengeist"
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"events_event_type",
sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("name", sa.String(length=30), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_events_event_type")),
sa.UniqueConstraint("name", name=op.f("uq_events_event_type_name")),
)
op.create_table(
"events_job_type",
sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("name", sa.String(length=30), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_events_job_type")),
sa.UniqueConstraint("name", name=op.f("uq_events_job_type_name")),
)
op.create_table(
"events_event",
sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("start", flaschengeist.database.types.UtcDateTime(), nullable=False),
sa.Column("end", flaschengeist.database.types.UtcDateTime(), nullable=True),
sa.Column("name", sa.String(length=255), nullable=True),
sa.Column("description", sa.String(length=512), nullable=True),
sa.Column("is_template", sa.Boolean(), nullable=True),
sa.Column("type_id", flaschengeist.database.types.Serial(), nullable=False),
sa.ForeignKeyConstraint(
["type_id"],
["events_event_type.id"],
name=op.f("fk_events_event_type_id_events_event_type"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_events_event")),
)
op.create_table(
"events_job",
sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("start", flaschengeist.database.types.UtcDateTime(), nullable=False),
sa.Column("end", flaschengeist.database.types.UtcDateTime(), nullable=True),
sa.Column("comment", sa.String(length=256), nullable=True),
sa.Column("type_id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("locked", sa.Boolean(), nullable=False),
sa.Column("required_services", sa.Numeric(precision=4, scale=2, asdecimal=False), nullable=False),
sa.Column("event_id", flaschengeist.database.types.Serial(), nullable=False),
sa.ForeignKeyConstraint(["event_id"], ["events_event.id"], name=op.f("fk_events_job_event_id_events_event")),
sa.ForeignKeyConstraint(
["type_id"], ["events_job_type.id"], name=op.f("fk_events_job_type_id_events_job_type")
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_events_job")),
sa.UniqueConstraint("type_id", "start", "event_id", name="_type_start_uc"),
)
op.create_table(
"events_invitation",
sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("time", flaschengeist.database.types.UtcDateTime(), nullable=False),
sa.Column("job_id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("invitee_id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("inviter_id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("transferee_id", flaschengeist.database.types.Serial(), nullable=True),
sa.ForeignKeyConstraint(["invitee_id"], ["user.id"], name=op.f("fk_events_invitation_invitee_id_user")),
sa.ForeignKeyConstraint(["inviter_id"], ["user.id"], name=op.f("fk_events_invitation_inviter_id_user")),
sa.ForeignKeyConstraint(["job_id"], ["events_job.id"], name=op.f("fk_events_invitation_job_id_events_job")),
sa.ForeignKeyConstraint(["transferee_id"], ["user.id"], name=op.f("fk_events_invitation_transferee_id_user")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_events_invitation")),
)
op.create_table(
"events_service",
sa.Column("is_backup", sa.Boolean(), nullable=True),
sa.Column("value", sa.Numeric(precision=3, scale=2, asdecimal=False), nullable=False),
sa.Column("job_id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("user_id", flaschengeist.database.types.Serial(), nullable=False),
sa.ForeignKeyConstraint(["job_id"], ["events_job.id"], name=op.f("fk_events_service_job_id_events_job")),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_events_service_user_id_user")),
sa.PrimaryKeyConstraint("job_id", "user_id", name=op.f("pk_events_service")),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("events_service")
op.drop_table("events_invitation")
op.drop_table("events_job")
op.drop_table("events_event")
op.drop_table("events_job_type")
op.drop_table("events_event_type")
# ### end Alembic commands ###

View File

@ -0,0 +1,146 @@
from __future__ import annotations # TODO: Remove if python requirement is >= 3.10
from datetime import datetime
from typing import Optional, Union, List
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):
__allow_unmapped__ = True
__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):
__allow_unmapped__ = True
__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):
__allow_unmapped__ = True
__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)
comment: Optional[str] = db.Column(db.String(256))
type: int = db.Column("type_id", Serial, db.ForeignKey(f"{_table_prefix_}job_type.id"), nullable=False)
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)
type_: JobType = db.relationship("JobType")
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"),)
##########
# Events #
##########
class Event(db.Model, ModelSerializeMixin):
"""Model for an Event"""
__allow_unmapped__ = True
__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):
__allow_unmapped__ = True
__tablename__ = _table_prefix_ + "invitation"
id: int = db.Column(Serial, primary_key=True)
time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc)
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

View File

@ -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("_")]

View File

@ -0,0 +1,567 @@
from http.client import NO_CONTENT
from flask import request, jsonify, Blueprint
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 flaschengeist import logger
from . import event_controller, permissions, EventPlugin
blueprint = Blueprint("events", __name__)
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, TypeError):
rv = default
return rv
@blueprint.route("/events/templates", methods=["GET"])
@login_required()
def get_templates(current_session):
return jsonify(event_controller.get_templates())
@blueprint.route("/events/event-types", methods=["GET"])
@blueprint.route("/events/event-types/<int:identifier>", 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/<identifier>`` | 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)
@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)
@blueprint.route("/events/event-types/<int:identifier>", 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/<id>`` | 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
@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)
@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)
@blueprint.route("/events/job-types/<int:type_id>", 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/<name>`` | 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
@blueprint.route("/events/<int:event_id>", methods=["GET"])
@login_required()
def get_event(event_id, current_session):
"""Get event by id
Route: ``/events/<event_id>`` | 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)
@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 = int(data["type"])
job_type = event_controller.get_job_type(job_type)
job_id = dict_get(data, "id", None, int)
if job_id:
job = next(job for job in event.jobs if job.id == job_id)
job.event = event
job.job_type = job_type
job.start = start
job.end = end
job.required_services = required_services
else:
event_controller.add_job(
event,
job_type,
required_services,
start,
end,
comment=dict_get(data, "comment", None, str),
)
except (KeyError, ValueError):
raise BadRequest("Missing or invalid POST parameter")
except StopIteration:
raise BadRequest("Job not in event")
def _delete_jobs_from_event(event, data):
job_ids = [x["id"] for x in data if "id" in x]
for job in event.jobs:
if job.id not in job_ids:
event.jobs.remove(job)
@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)
event_type = event_controller.get_event_type(int(data["type"]))
event = event_controller.create_event(
start=start,
end=end,
name=dict_get(data, "name", None, type=str),
is_template=dict_get(data, "is_template", None, type=bool),
event_type=event_type,
description=dict_get(data, "description", None, type=str),
)
if "jobs" in data:
for job in data["jobs"]:
_add_job(event, job)
return jsonify(event)
except KeyError:
raise BadRequest("Missing POST parameter")
except (NotFound, ValueError):
raise BadRequest("Invalid parameter")
@blueprint.route("/events/<int:event_id>", methods=["PUT"])
@login_required(permission=permissions.EDIT)
def modify_event(event_id, current_session):
"""Modify an event
Route: ``/events/<event_id>`` | 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()
logger.debug("PUT data: %s", data)
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
if "jobs" in data:
_delete_jobs_from_event(event, data["jobs"])
for job in data["jobs"]:
_add_job(event, job)
event_controller.update()
return jsonify(event)
@blueprint.route("/events/<int:event_id>", methods=["DELETE"])
@login_required(permission=permissions.DELETE)
def delete_event(event_id, current_session):
"""Delete an event
Route: ``/events/<event_id>`` | 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
@blueprint.route("/events/<int:event_id>/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/<event_id>/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)
@blueprint.route("/events/<int:event_id>/jobs/<int:job_id>", methods=["DELETE"])
@login_required(permission=permissions.DELETE)
def delete_job(event_id, job_id, current_session):
"""Delete a Job
Route: ``/events/<event_id>/jobs/<job_id>`` | 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()
@blueprint.route("/events/<int:event_id>/jobs/<int:job_id>", methods=["PUT"])
@login_required()
def update_job(event_id, job_id, current_session: Session):
"""Edit Job
Route: ``/events/<event_id>/jobs/<job_id>`` | 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 or "type_id" in data:
job.type_ = event_controller.get_job_type(data.get("type", None) or data["type_id"])
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)
@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})
@blueprint.route("/events/jobs/<int:job_id>", methods=["GET"])
@login_required()
def get_job(job_id, current_session: Session):
return jsonify(event_controller.get_job(job_id))
@blueprint.route("/events/jobs/<int:job_id>/assign", methods=["POST"])
@login_required()
def assign_job(job_id, current_session: Session):
"""Assign / unassign user to the Job
Route: ``/events/jobs/<job_id>/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:
value = data["value"]
user = userController.get_user(
data["userid"], deleted=value < 0
) # allow unassigning deleted users, but not assigning
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), notify=user != current_session.user_
)
else:
event_controller.unassign_job(job, user, notify=user != current_session.user_)
except (TypeError, KeyError, ValueError):
raise BadRequest
return jsonify(job)
@blueprint.route("/events/jobs/<int:job_id>/lock", methods=["POST"])
@login_required(permissions.LOCK_JOBS)
def lock_job(job_id, current_session: Session):
"""Lock / unlock the Job
Route: ``/events/jobs/<job_id>/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()
@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[], transferee?: string}``
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 or 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_, userController.get_user(transferee) if transferee else None
)
for invitee in [userController.get_user(uid) for uid in data["invitees"]]
]
)
except (TypeError, KeyError, ValueError, NotFound):
raise BadRequest
@blueprint.route("/events/invitations", methods=["GET"])
@login_required()
def get_invitations(current_session: Session):
return jsonify(event_controller.get_invitations(current_session.user_))
@blueprint.route("/events/invitations/<int:invitation_id>", 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 NotFound
return jsonify(inv)
@blueprint.route("/events/invitations/<int:invitation_id>", 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()

3
backend/pyproject.toml Normal file
View File

@ -0,0 +1,3 @@
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"

34
backend/setup.cfg Normal file
View File

@ -0,0 +1,34 @@
[metadata]
license = MIT
version = 1.0.0
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]
python_requires = >=3.8
packages =
flaschengeist_events
install_requires =
flaschengeist >= 2.0.0
[options.package_data]
* = *.toml, script.py.mako, *.ini, migrations/*.py
[options.entry_points]
flaschengeist.plugins =
events = flaschengeist_events:EventPlugin

View File

@ -1,6 +1,6 @@
{
"license": "MIT",
"version": "1.0.0-alpha.5",
"version": "1.1.0",
"name": "@flaschengeist/schedule",
"author": "Ferdinand Thiessen <rpm@fthiessen.de>",
"homepage": "https://flaschengeist.dev/Flaschengeist",
@ -15,29 +15,31 @@
"main": "src/index.ts",
"types": "src/api.d.ts",
"scripts": {
"pretty": "prettier --config ./package.json --write '{,!(node_modules)/**/}*.ts'",
"format": "prettier --config ./package.json --write '{,!(node_modules|backend)/**/}*.{js,ts,vue}'",
"lint": "eslint --ext .js,.ts,.vue ./src"
},
"dependencies": {
"@quasar/quasar-ui-qcalendar": "^4.0.0-beta.10"
"@quasar/quasar-ui-qcalendar": "^4.0.0-beta.11"
},
"devDependencies": {
"@flaschengeist/types": "^1.0.0-alpha.8",
"@quasar/app": "^3.2.3",
"quasar": "^2.3.3",
"@flaschengeist/api": "^1.0.0",
"@flaschengeist/types": "^1.0.0",
"@quasar/app-webpack": "^3.7.2",
"@typescript-eslint/eslint-plugin": "^5.8.0",
"@typescript-eslint/parser": "^5.8.0",
"axios": "^0.24.0",
"prettier": "^2.4.1",
"typescript": "^4.5.2",
"pinia": "^2.0.4",
"@typescript-eslint/eslint-plugin": "^5.4.0",
"@typescript-eslint/parser": "^5.4.0",
"eslint": "^8.3.0",
"eslint": "^8.5.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-vue": "^8.1.1"
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-vue": "^8.2.0",
"pinia": "^2.0.8",
"prettier": "^2.5.1",
"quasar": "^2.11.10",
"typescript": "^4.5.4"
},
"peerDependencies": {
"@flaschengeist/api": "^1.0.0-alpha.4",
"@flaschengeist/users": "^1.0.0-alpha.1"
"@flaschengeist/api": "^1.0.0",
"@flaschengeist/users": "^1.0.0"
},
"prettier": {
"singleQuote": true,

2
src/api.d.ts vendored
View File

@ -15,11 +15,11 @@ declare namespace FG {
}
interface Invitation {
id: number;
time: Date;
job_id: number;
invitee_id: string;
inviter_id: string;
transferee_id?: string;
transferee: User;
}
interface Job {
id: number;

View File

@ -2,9 +2,9 @@
<q-card style="text-align: center">
<q-card-section class="row justify-center items-center content-center">
<div class="col-5">
<q-icon :name="jobs == 0 ? 'mdi-calendar-blank' : 'mdi-calendar-alert'" :size="divHeight" />
<q-icon :name="jobs == 0 ? 'mdi-calendar-blank' : 'mdi-calendar-alert'" :size="divHeight" />
</div>
<div v-if="(jobs || 0) > 0" ref="div" class="col-7">
<div v-if="(jobs || 0) > 0" ref="div" class="col-7">
<div class="text-h6">Anstehende Dienste</div>
<div class="text-body1">{{ jobs }}</div>
<div class="text-h6">Nächster Dienst</div>

View File

@ -52,6 +52,7 @@
class="col-xs-12 col-sm-6 q-pa-sm"
label="Veranstaltungsende"
:rules="[afterStart]"
:key="update_time"
/>
<q-input
v-model="event.description"
@ -61,7 +62,7 @@
filled
/>
</q-card-section>
<q-card-section v-if="event.is_template !== true && modelValue === undefined">
<q-card-section v-if="modelValue === undefined">
<q-btn-toggle
v-model="recurrent"
spread
@ -81,14 +82,14 @@
<q-btn color="primary" label="Schicht hinzufügen" @click="addJob()" />
</div>
</div>
<template v-for="(job, index) in event.jobs" :key="index">
<template v-for="(job, index) in event.jobs" :key="index + update_time">
<edit-job-slot
:ref="active === index ? 'activeJob' : undefined"
ref="activeJob"
v-model="event.jobs[index]"
:active="index === active"
class="q-mb-md"
@activate="activate(index)"
@remove-job="removeJob(index)"
@activate="activate(index)"
/>
</template>
</q-card-section>
@ -111,9 +112,9 @@ import { notEmpty } from '@flaschengeist/api';
import { IsoDateInput } from '@flaschengeist/api/components';
import { useEventStore } from '../../store';
import { emptyEvent, emptyJob, EditableEvent } from '../../store/models';
import { emptyEvent, Job, EditableEvent } from '../../store/models';
import { date, ModifyDateOptions } from 'quasar';
import { date, DateOptions } from 'quasar';
import { computed, defineComponent, PropType, ref, onBeforeMount, watch } from 'vue';
import EditJobSlot from './EditJobSlot.vue';
@ -137,22 +138,13 @@ export default defineComponent({
},
emits: {
done: (val: boolean) => typeof val === 'boolean',
'update:modelValue': (val?: FG.Event) => typeof val === 'object',
},
setup(props, { emit }) {
const store = useEventStore();
const startDate = computed(() => {
const d = date.buildDate({ milliseconds: 0, seconds: 0, minutes: 0, hours: 0 });
if (!props.date || !date.isValid(props.date)) return d;
const split = props.date.split('-');
return date.adjustDate(d, {
year: parseInt(split[0]),
month: parseInt(split[1]),
date: parseInt(split[2]),
});
});
const active = ref(0);
const activeJob = ref<{ validate: () => Promise<boolean> }>();
const activeJob = ref<{ validate: () => Promise<boolean> }[]>([]);
const templates = computed(() => store.templates);
const template = ref<FG.Event>();
const event = ref<EditableEvent>(props.modelValue || emptyEvent());
@ -166,16 +158,20 @@ export default defineComponent({
void store.getTemplates();
});
watch(props, (n, o) => {
if (event.value?.id !== n.modelValue?.id) reset();
});
watch(
() => props.modelValue,
(newModelValue) => {
if (event.value?.id !== newModelValue?.id) reset();
}
);
function addJob() {
if (!activeJob.value) event.value.jobs.push(emptyJob());
else
void activeJob.value.validate().then((success) => {
if (!activeJob.value[active.value]) {
event.value.jobs.push(new Job());
} else
void activeJob.value[active.value].validate().then((success) => {
if (success) {
event.value.jobs.push(emptyJob());
event.value.jobs.push(new Job());
active.value = event.value.jobs.length - 1;
}
});
@ -187,7 +183,7 @@ export default defineComponent({
}
function fromTemplate(tpl: FG.Event) {
const today = new Date();
const today = props.modelValue?.start || new Date();
template.value = tpl;
event.value = Object.assign({}, tpl, { id: undefined });
@ -201,23 +197,31 @@ export default defineComponent({
const diff = event.value.start.getTime() - tpl.start.getTime();
// Adjust end of event and all jobs
if (event.value.end) event.value.end.setTime(event.value.end.getTime() + diff);
event.value.jobs.forEach((job) => {
job.start.setTime(job.start.getTime() + diff);
if (job.end) job.end.setTime(job.end.getTime() + diff);
event.value.jobs = [];
tpl.jobs.forEach((job) => {
const copied_job: FG.Job = Object.assign({}, job, {
id: NaN,
start: new Date(),
end: undefined,
});
copied_job.start.setTime(job.start.getTime() + diff);
if (job.end) {
copied_job.end = new Date();
copied_job.end.setTime(job.end.getTime() + diff);
}
event.value.jobs.push(<Job>copied_job);
});
}
async function save(template = false) {
event.value.is_template = template;
async function save(is_template = false) {
event.value.is_template = is_template;
try {
if (event.value?.id !== undefined) {
//fix
}
await store.addEvent(event.value);
const _event = await store.addEvent(event.value);
emit('update:modelValue', _event);
if (props.modelValue === undefined && recurrent.value && !event.value.is_template) {
let count = 0;
const options: ModifyDateOptions = {};
const options: DateOptions = {};
switch (recurrenceRule.value.frequency) {
case 'daily':
options['days'] = 1 * recurrenceRule.value.interval;
@ -270,12 +274,60 @@ export default defineComponent({
!d || event.value.start <= d || 'Das Veranstaltungsende muss vor dem Beginn liegen';
function activate(idx: number) {
void activeJob.value?.validate().then((s) => {
void activeJob.value[active.value]?.validate().then((s) => {
if (s) active.value = idx;
});
}
const computed_start = computed({
get: () => event.value?.start,
set: (value) => {
event.value.start = value;
},
});
const computed_end = computed({
get: () => event.value?.end,
set: (value) => {
event.value.end = value;
},
});
const update_time = ref(false);
watch(computed_start, (newValue, oldValue) => {
update_time.value = true;
const diff = newValue.getTime() - oldValue.getTime();
event.value?.jobs.forEach((job) => {
job.start.setTime(job.start.getTime() + diff);
job.end?.setTime(job.end.getTime() + diff);
});
computed_end.value?.setTime(computed_end.value?.getTime() + diff);
setTimeout(() => {
update_time.value = false;
}, 0);
});
watch(computed_end, (newValue, oldValue) => {
if (newValue && oldValue) {
update_time.value = true;
if (!newValue || !oldValue) return;
const diff = newValue.getTime() - oldValue.getTime();
event.value?.jobs.forEach((job) => {
if (job.end) job.end.setTime(job.end.getTime() + diff);
else job.end = new Date(newValue.getTime());
});
} else if (newValue && !oldValue) {
event.value?.jobs.forEach((job) => {
if (!job.end) job.end = new Date(newValue.getTime());
});
}
setTimeout(() => {
update_time.value = false;
}, 0);
});
return {
update_time,
activate,
active,
addJob,

View File

@ -33,19 +33,19 @@
input-debounce="0"
class="col-xs-12 col-sm-6 q-pa-sm"
:options="jobtypes"
option-label="name"
:option-label="(jobtype) => (typeof jobtype === 'number' ? '' : jobtype.name)"
option-value="id"
map-options
clearable
:rules="[notEmpty]"
/>
<q-input
v-model="job.required_services"
v-model.number="job.required_services"
filled
class="col-xs-12 col-sm-6 q-pa-sm"
label="Dienstanzahl"
type="number"
:rules="[notEmpty]"
:rules="[minOneService, notEmpty]"
/>
<q-input
v-model="job.comment"
@ -57,7 +57,12 @@
</q-form>
</q-card-section>
<q-card-actions>
<q-btn label="Schicht löschen" color="negative" :disabled="canDelete" @click="$emit('remove-job')" />
<q-btn
label="Schicht löschen"
color="negative"
:disabled="canDelete"
@click="$emit('remove-job')"
/>
</q-card-actions>
</q-card>
</template>
@ -126,9 +131,13 @@ export default defineComponent({
}
expose({
validate: () => form.value?.validate() || Promise.resolve(true)
validate: () => form.value?.validate() || Promise.resolve(true),
});
function minOneService(val: number) {
return parseInt(val) > 0 || 'Mindestens ein Dienst nötig';
}
return {
form,
formatStartEnd,
@ -136,11 +145,11 @@ export default defineComponent({
job,
jobtypes,
notEmpty,
minOneService,
typeName,
};
},
});
</script>
<style></style>

View File

@ -3,47 +3,53 @@
<q-dialog v-model="dialogOpen">
<q-card>
<q-card-section>
<div class="text-h6">Editere {{title}} {{ actualType.name }}</div>
<div class="text-h6">Editere {{ title }} {{ actualType.name }}</div>
</q-card-section>
<q-card-section>
<q-input ref="dialogInput" v-model="actualType.name" :rules="rules" dense label="name" filled />
<q-input
ref="dialogInput"
v-model="actualType.name"
:rules="rules"
dense
label="name"
filled
/>
</q-card-section>
<q-card-actions>
<q-btn flat color="danger" label="Abbrechen" @click="discardChanges()" />
<q-btn flat color="primary" label="Speichern" :disable="!!dialogInput && !dialogInput.validate()" @click="saveChanges()" />
<q-btn
flat
color="primary"
label="Speichern"
:disable="!!dialogInput && !dialogInput.validate()"
@click="saveChanges()"
/>
</q-card-actions>
</q-card>
</q-dialog>
<q-card>
<q-card-section>
<q-table :title="title" :rows="rows" row-key="id" :columns="columns">
<template #top-right>
<q-input
ref="input"
v-model="actualType.name"
:rules="rules"
dense
placeholder="Neuer Typ"
>
<slot name="after"
><q-btn color="primary" icon="mdi-plus" title="Hinzufügen" @click="addType"
/></slot>
</q-input>
</template>
<template #body-cell-actions="props">
<q-td :props="props" align="right" :auto-width="true">
<q-btn
round
icon="mdi-pencil"
@click="editType(props.row.id)"
/>
<q-btn round icon="mdi-delete" @click="deleteType(props.row.id)" />
</q-td>
</template>
</q-table>
</q-card-section>
</q-card>
<q-table :title="title" :rows="rows" row-key="id" :columns="columns">
<template #top-right>
<q-input
ref="input"
v-model="actualType.name"
:rules="rules"
dense
filled
placeholder="Neuer Typ"
>
<template #after
><q-btn color="primary" icon="mdi-plus" title="Hinzufügen" round @click="addType"
/></template>
</q-input>
</template>
<template #body-cell-actions="props">
<q-td :props="props" align="right" :auto-width="true">
<q-btn round icon="mdi-pencil" @click="editType(props.row.id)" />
<q-btn round icon="mdi-delete" @click="deleteType(props.row.id)" />
</q-td>
</template>
</q-table>
</div>
</template>
@ -56,9 +62,9 @@ import { useQuasar, QInput } from 'quasar';
export default defineComponent({
name: 'ManageTypes',
components: {},
props:{
type: {type: String as PropType<'EventType' | 'JobType'>, required: true},
title: {type: String, required: true}
props: {
type: { type: String as PropType<'EventType' | 'JobType'>, required: true },
title: { type: String, required: true },
},
setup(props) {
const store = useEventStore();
@ -69,17 +75,16 @@ export default defineComponent({
const actualType = ref(emptyType);
const input = ref<QInput>();
const dialogInput = ref<QInput>();
const storeName = computed(() => props.type == 'EventType' ? 'eventTypes' : 'jobTypes')
const storeName = computed(() => (props.type == 'EventType' ? 'eventTypes' : 'jobTypes'));
onBeforeMount(async () => await store[`get${props.type}s`]());
const rows = computed(() => <(FG.EventType|FG.JobType)[]>store[storeName.value]);
const rows = computed(() => <(FG.EventType | FG.JobType)[]>store[storeName.value]);
const rules = [
(s: unknown) => !!s || 'Darf nicht leer sein!',
(s: string) =>
rows.value.find((e) => e.name === s) === undefined ||
'Der Name wird bereits verwendet',
rows.value.find((e) => e.name === s) === undefined || 'Der Name wird bereits verwendet',
];
const columns = [
@ -100,8 +105,7 @@ export default defineComponent({
function addType() {
if (input.value === undefined || input.value.validate())
store
[`add${props.type}`](actualType.value.name)
store[`add${props.type}`](actualType.value.name)
.then(() => {
actualType.value.name = '';
})
@ -121,12 +125,17 @@ export default defineComponent({
function editType(id: number) {
dialogOpen.value = true;
actualType.value = Object.assign({}, rows.value.find((v) => v.id === id));
actualType.value = Object.assign(
{},
rows.value.find((v) => v.id === id)
);
}
function saveChanges() {
if (dialogInput.value === undefined || dialogInput.value.validate())
void store[`rename${props.type}`](actualType.value.id, actualType.value.name).then(() => discardChanges());
void store[`rename${props.type}`](actualType.value.id, actualType.value.name).then(() =>
discardChanges()
);
}
function discardChanges() {

View File

@ -63,7 +63,7 @@ export default defineComponent({
const rule = new Proxy(props.modelValue, {
get(target, prop) {
if (typeof prop === 'string') {
return ((props.modelValue as unknown) as Record<string, unknown>)[prop];
return (props.modelValue as unknown as Record<string, unknown>)[prop];
}
},
set(target, prop, value) {

View File

@ -27,7 +27,7 @@
<q-popup-proxy ref="proxy" transition-show="scale" transition-hide="scale">
<q-date
ref="datepicker"
:model-value="selectedDate"
:model-value="date(selectedDate)"
mask="YYYY-MM-DD"
no-unset
@update:model-value="updateDate"
@ -53,7 +53,7 @@
</div>
</div>
<q-calendar-agenda
v-model="selectedDate"
:model-value="date(realDate)"
:view="calendarRealView"
:max-days="calendarDays"
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
@ -88,14 +88,16 @@
</template>
<script lang="ts">
import { ComponentPublicInstance, computed, defineComponent, onBeforeMount, ref } from 'vue';
import { useEventStore } from '../../store';
import EventSlot from './slots/EventSlot.vue';
import { date, QDate, QPopupProxy, useQuasar } from 'quasar';
import { startOfWeek } from '@flaschengeist/api';
import EditEvent from '../management/EditEvent.vue';
import { ComponentPublicInstance, computed, defineComponent, onBeforeMount, ref, watch } from 'vue';
import { QCalendarAgenda } from '@quasar/quasar-ui-qcalendar';
import { date, QDate, QPopupProxy, useQuasar } from 'quasar';
import { useRoute, useRouter } from 'vue-router';
import { EditableEvent, emptyEvent } from '../../store/models';
import { startOfWeek } from '@flaschengeist/api';
import { useEventStore } from '../../store';
import EventSlot from './slots/EventSlot.vue';
import EditEvent from '../management/EditEvent.vue';
export default defineComponent({
name: 'AgendaView',
@ -104,17 +106,36 @@ export default defineComponent({
setup() {
const store = useEventStore();
const quasar = useQuasar();
const route = useRoute();
const router = useRouter();
const datepicker = ref<QDate>();
const proxy = ref<QPopupProxy>();
const selectedDate = ref(date.formatDate(new Date(), 'YYYY-MM-DD'));
// User selectable (day vs week)
const calendarView = ref('week');
// User selected date
const selectedDate = ref(date.buildDate({ hours: 0, minutes: 0, seconds: 0, milliseconds: 0 }));
const calendarRealView = computed(() => (calendarDays.value != 7 ? 'day' : 'week'));
const calendarDays = computed(() =>
calendarView.value == 'day' || quasar.screen.xs ? 1 : quasar.screen.sm ? 3 : 7
// Real view depending on the screen size
const calendarRealView = computed(() =>
calendarView.value === 'day' || quasar.screen.xs || quasar.screen.sm ? 'day' : 'week'
);
const calendarDays = computed(() => {
if (calendarView.value == 'day' || quasar.screen.xs) return 1;
if (calendarRealView.value == 'week') return 7;
return realDate.value.getDay() === 1 ? 3 : 4;
});
const realDate = computed(() => {
if (calendarView.value === 'day' || calendarRealView.value === 'week' || quasar.screen.xs)
return selectedDate.value;
const start = startOfWeek(selectedDate.value);
if (selectedDate.value.getDay() > 0 && selectedDate.value.getDay() <= 3) return start;
else return date.addToDate(start, { days: 3 });
});
const events = ref<Agendas>({});
const editor = ref<EditableEvent>();
@ -123,6 +144,19 @@ export default defineComponent({
}
onBeforeMount(async () => {
await router.replace({ query: { ...route.query, q_tab: 'agendaView' } });
if (!Object.keys(route.query).includes('q_date')) {
const q_date = date.formatDate(selectedDate.value, 'YYYY-MM-DD');
await router.replace({ query: { ...route.query, q_date } });
} else {
selectedDate.value = date.extractDate(route.query.q_date as string, 'YYYY-MM-DD');
}
if (!Object.keys(route.query).includes('q_view')) {
const q_view = calendarView.value;
await router.replace({ query: { ...route.query, q_view } });
} else {
calendarView.value = route.query.q_view as string;
}
await loadAgendas();
});
@ -153,89 +187,98 @@ export default defineComponent({
}
}
const loading = ref(false);
async function loadAgendas() {
const selected = date.adjustDate(selectedDate.value, {
milliseconds: 0,
seconds: 0,
minutes: 0,
hours: 0,
});
const start = calendarView.value === 'day' ? selected : startOfWeek(selected);
const end = date.addToDate(start, { days: calendarDays.value });
if (loading.value) return;
loading.value = true;
const from =
calendarView.value === 'day' ? selectedDate.value : startOfWeek(selectedDate.value);
const to = date.addToDate(from, { days: calendarView.value === 'day' ? 1 : 7 });
events.value = {};
const { result } = await store.getEvents({ from: start, to: end });
const { result } = await store.getEvents({ from, to });
result.forEach((event) => {
const day = event.start.getDay();
if (!events.value[day]) {
events.value[day] = [];
}
events.value[day].push(event);
const idx = events.value[day].findIndex((e) => e.id === event.id);
if (idx === -1) events.value[day].push(event);
else events.value[day][idx] = event;
});
loading.value = false;
}
function calendarNext() {
selectedDate.value = date.formatDate(
date.addToDate(selectedDate.value, { days: calendarDays.value }),
'YYYY-MM-DD'
);
void loadAgendas();
function addDays(reverse: boolean) {
const oww = Math.floor((startOfWeek(selectedDate.value).getTime() / 1000) * 60 * 60 * 24);
selectedDate.value = date.addToDate(realDate.value, {
days: reverse ? -1 : calendarDays.value,
});
if (oww != Math.floor((startOfWeek(selectedDate.value).getTime() / 1000) * 60 * 60 * 24))
void loadAgendas();
}
function calendarPrev() {
selectedDate.value = date.formatDate(
date.subtractFromDate(selectedDate.value, { days: calendarDays.value }),
'YYYY-MM-DD'
);
void loadAgendas();
function asMonth(value?: Date) {
return [
'Januar',
'Februar',
'März',
'April',
'Mai',
'Juni',
'Juli',
'August',
'September',
'Oktober',
'November',
'Dezember',
'-',
][value?.getMonth() === 0 ? 0 : value?.getMonth() || 12];
}
function asYear(value?: Date) {
return value?.getFullYear() || '-';
}
function asMonth(value: string) {
if (value) {
return date.formatDate(new Date(value), 'MMMM', {
months: [
'Januar',
'Februar',
'März',
'April',
'Mai',
'Juni',
'Juli',
'August',
'September',
'Oktober',
'November',
'Dezember',
],
});
}
}
function asYear(value: string) {
if (value) {
return date.formatDate(new Date(value), 'YYYY');
}
}
watch(selectedDate, async (newValue) => {
const q_date = date.formatDate(newValue, 'YYYY-MM-DD');
await router.replace({ query: { ...route.query, q_date } });
await loadAgendas();
});
watch(calendarView, async (newValue) => {
const q_view = newValue;
await router.replace({ query: { ...route.query, q_view } });
await loadAgendas();
});
return {
asYear,
asMonth,
calendarDays,
calendarNext,
calendarPrev,
calendarNext: () => addDays(false),
calendarPrev: () => addDays(true),
calendarRealView,
calendarView,
create,
date: (d: Date) => date.formatDate(d, 'YYYY-MM-DD'),
edit,
editor,
editDone,
events,
datepicker,
proxy,
realDate,
remove,
selectedDate,
updateDate: (ds: string) => {
selectedDate.value = ds;
selectedDate.value = date.adjustDate(date.extractDate(ds, 'YYYY-MM-DD'), {
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 0,
});
proxy.value?.hide();
},
};

View File

@ -16,31 +16,31 @@
</div>
</q-card>
</q-dialog>
<div class="q-pa-md">
<q-card style="height: 70vh; max-width: 1800px" class="q-pa-md">
<div class="scroll" ref="scrollDiv" style="height: 100%">
<q-infinite-scroll :offset="250" @load="load">
<q-list>
<q-item id="bbb">
<q-btn label="Ältere Veranstaltungen laden" @click="load(-1)" />
</q-item>
<template v-for="(events, index) in agendas" :key="index">
<q-separator />
<q-item-label overline>{{ index }}</q-item-label>
<q-item v-for="(event, idx) in events" :key="idx"
><event-slot :model-value="event" />{{ idx }}</q-item
>
</template>
</q-list>
<template #loading>
<div class="row justify-center q-my-md">
<q-spinner-dots color="primary" size="40px" />
</div>
</template>
</q-infinite-scroll>
</div>
</q-card>
<!-- <div class="q-pa-md"> -->
<!-- <q-card style="height: 70vh; max-width: 1800px" class="q-pa-md"> -->
<div ref="scrollDiv" class="scroll" style="height: 100%">
<q-infinite-scroll :offset="250" @load="load">
<q-list>
<q-item id="bbb">
<q-btn label="Ältere Veranstaltungen laden" @click="load(-1)" />
</q-item>
<template v-for="(events, index) in agendas" :key="index">
<q-separator />
<q-item-label header>{{ asDate(index) }}</q-item-label>
<q-item v-for="(event, idx) in events" :key="idx">
<event-slot v-model="events[idx]" @edit-event="edit" @remove-event="remove" />
</q-item>
</template>
</q-list>
<template #loading>
<div class="row justify-center q-my-md">
<q-spinner-dots color="primary" size="40px" />
</div>
</template>
</q-infinite-scroll>
</div>
<!-- </q-card> -->
<!-- </div> -->
</template>
<script lang="ts">
@ -88,6 +88,10 @@ export default defineComponent({
function editDone(changed: boolean) {
//if (changed) void loadAgendas();
const idx = events.value.findIndex((event) => event.id === editor.value?.id);
if (idx >= 0) {
events.value[idx] = editor.value as FG.Event;
}
editor.value = undefined;
}
@ -95,7 +99,12 @@ export default defineComponent({
const start = new Date();
if (index < 0) {
const { result } = await store.getEvents({ to: start, limit: 5, descending: true });
events.value.unshift(...result);
//events.value.unshift(...result);
for (const event of result) {
const idx = events.value.findIndex((e) => e.id === event.id);
if (idx === -1) events.value.unshift(event);
else events.value[idx] = event;
}
if (done) done(false);
} else {
const len = events.value.length;
@ -104,7 +113,12 @@ export default defineComponent({
offset: (index - 1) * 10,
limit: 10,
});
if (len == events.value.push(...result)) {
for (const event of result) {
const idx = events.value.findIndex((e) => e.id === event.id);
if (idx === -1) events.value.unshift(event);
else events.value[idx] = event;
}
if (len == events.value.length) {
if (done) return done(true);
} else if (done) done(false);
}
@ -117,16 +131,10 @@ export default defineComponent({
async function remove(id: number) {
if (await store.removeEvent(id)) {
// Successfull removed
for (const idx in agendas.value) {
const i = agendas.value[idx].findIndex((event) => event.id === id);
if (i !== -1) {
agendas.value[idx].splice(i, 1);
break;
}
const idx = events.value.findIndex((event) => event.id === id);
if (idx !== -1) {
events.value.splice(idx, 1);
}
} else {
// Not found, this means our eventa are outdated
}
}
@ -157,6 +165,15 @@ export default defineComponent({
}
}
function asDate(value: string) {
if (value) {
const year = parseInt(value.substring(0, 4));
const month = parseInt(value.substring(4, 6)) - 1;
const day = parseInt(value.substring(6, 8));
return date.formatDate(new Date(year, month, day), 'DD.MM.YYYY');
}
}
return {
agendas,
asYear,
@ -167,6 +184,7 @@ export default defineComponent({
editDone,
load,
remove,
asDate,
};
},
});

View File

@ -13,26 +13,52 @@
<div>
<q-select
:model-value="modelValue.services"
:disable="!canAssignOther || modelValue.locked"
:options="options"
option-value="userid"
filled
:option-label="(opt) => userDisplay(opt)"
multiple
disable
use-chips
stack-label
use-input
label="Dienste"
behavior="dialog"
class="col-auto q-px-xs"
style="font-size: 6px"
counter
:max-values="modelValue.required_services"
@filter="filterUsers"
@add="({ value }) => assign(value)"
@remove="({ value }) => unassign(value)"
>
<template #selected-item="{ opt, toggleOption }">
<service-user-chip :model-value="opt" removeable @remove="toggleOption" />
</template>
<template #option="{ opt, itemProps }">
<q-item v-bind="itemProps">
<q-item-section avatar>
<user-avatar :model-value="opt.userid" />
</q-item-section>
<q-item-section>{{ userDisplay(opt.userid) }}</q-item-section>
<q-item-section style="max-width: 10em" side>
<q-input
v-model.number="opt.value"
type="number"
min="0"
step="0.25"
dense
filled
@click.stop=""
/>
</q-item-section>
<q-item-section side>
<q-toggle v-model="opt.is_backup" label="Backup" left-label />
</q-item-section>
</q-item>
</template>
</q-select>
<div class="row col-12 justify-end">
<q-btn
v-if="!modelValue.locked && !isEnrolled && !isFull"
v-if="!modelValue.locked && !isEnrolled && !isFull && canAssign"
flat
color="primary"
label="Eintragen"
@click="assignJob()"
@click="assign()"
/>
<q-btn v-if="isEnrolled && !modelValue.locked" flat color="secondary" label="Optionen">
<q-menu auto-close>
@ -43,8 +69,18 @@
<q-item clickable @click="transfer">
<q-item-section>Tauschen</q-item-section>
</q-item>
<q-item v-if="isBackup" clickable @click="backup(false)">
<q-tooltip>Backup zu vollem Dienst machen</q-tooltip>
<q-item-section>Dienst</q-item-section>
<q-item-section side><q-icon name="mdi-eye" /></q-item-section>
</q-item>
<q-item v-else clickable @click="backup(true)">
<q-tooltip>Nur als Backup eintragen</q-tooltip>
<q-item-section>Backup</q-item-section>
<q-item-section side><q-icon name="mdi-eye-off" /></q-item-section>
</q-item>
<q-separator />
<q-item clickable @click="assignJob(false)">
<q-item clickable @click="unassign()">
<q-item-section class="text-negative">Austragen</q-item-section>
</q-item>
</q-list>
@ -56,14 +92,20 @@
</template>
<script lang="ts">
import { defineComponent, onBeforeMount, computed, PropType } from 'vue';
import { date, useQuasar } from 'quasar';
import { asHour, useMainStore, useUserStore } from '@flaschengeist/api';
import { defineComponent, onBeforeMount, computed, ref, PropType } from 'vue';
import { asHour, hasPermission, useMainStore, useUserStore } from '@flaschengeist/api';
import { useEventStore } from '../../../store';
import { PERMISSIONS } from '../../../permissions';
import TransferInviteDialog from './TransferInviteDialog.vue';
import ServiceUserChip from './ServiceUserChip.vue';
import { UserAvatar } from '@flaschengeist/api/components';
import { DisplayNameMode } from '@flaschengeist/users';
export default defineComponent({
name: 'JobSlot',
components: { ServiceUserChip, UserAvatar },
props: {
modelValue: {
required: true,
@ -81,31 +123,65 @@ export default defineComponent({
const userStore = useUserStore();
const quasar = useQuasar();
onBeforeMount(async () => userStore.getUsers());
// Make sure users are loaded if we can assign them
onBeforeMount(() => {
void userStore.getUsers();
void userStore.getDisplayNameModeSetting(true);
});
function userDisplay(service: FG.Service) {
return userStore.findUser(service.userid)?.display_name || service.userid;
/* Stuff used for general display */
// Get displayname of user
function userDisplay(id: string) {
switch (userStore.userSettings.display_name) {
case DisplayNameMode.FIRSTNAME:
return userStore.findUser(id)?.firstname || id;
case DisplayNameMode.LASTNAME:
return userStore.findUser(id)?.lastname || id;
case DisplayNameMode.DISPLAYNAME:
return userStore.findUser(id)?.display_name || id;
case DisplayNameMode.FIRSTNAME_LASTNAME:
return (
`${<string>userStore.findUser(id)?.firstname} ${<string>(
userStore.findUser(id)?.lastname
)}` || id
);
case DisplayNameMode.LASTNAME_FIRSTNAME:
return (
`${<string>userStore.findUser(id)?.lastname}, ${<string>(
userStore.findUser(id)?.firstname
)}` || id
);
}
return userStore.findUser(id)?.display_name || id;
}
// The name of the current job
const typeName = computed(() =>
typeof props.modelValue.type === 'object'
? props.modelValue.type.name
: store.jobTypes.find((j) => j.id === props.modelValue.type)?.name || 'Unbekannter Diensttyp'
: store.jobTypes.find((j) => j.id === props.modelValue.type)?.name ||
'Unbekannter Diensttyp'
);
const isEnrolled = computed(
() =>
props.modelValue.services.findIndex(
(service) => service.userid == mainStore.currentUser.userid
) !== -1
// The service of the current user if self assigned to the job
const service = computed(() =>
props.modelValue.services.find((service) => service.userid == mainStore.currentUser.userid)
);
// Weather the current user is assigned to the job
const isEnrolled = computed(() => service.value !== undefined);
// If the job has enough assigned services
const isFull = computed(
() =>
props.modelValue.services.map((s) => s.value).reduce((p, c) => p + c, 0) >=
props.modelValue.required_services
);
// If current user is only backup service
const isBackup = computed(() => service.value?.is_backup || false);
// If it is still possible to invite other users (= job is today or in the future)
const canInvite = computed(
() =>
(props.modelValue.end || props.modelValue.start) >
@ -115,14 +191,15 @@ export default defineComponent({
)
);
async function assignJob(assign = true) {
const newService: FG.Service = {
// Assign user to a job
async function assign(service?: FG.Service) {
service = service || {
userid: mainStore.currentUser.userid,
is_backup: false,
value: assign ? 1 : -1,
value: 1,
};
try {
const job = await store.assignToJob(props.modelValue.id, newService);
const job = await store.assignToJob(props.modelValue.id, service);
emit('update:modelValue', job);
} catch (error) {
console.warn(error);
@ -137,6 +214,7 @@ export default defineComponent({
}
}
// open invite dialog (or transfer)
function invite(isInvite = true) {
quasar.dialog({
component: TransferInviteDialog,
@ -147,15 +225,75 @@ export default defineComponent({
});
}
/* Stuff needed if we can assign other user */
// Current user can assign other users
const canAssignOther = computed(() => hasPermission(PERMISSIONS.ASSIGN_OTHER));
const canAssign = computed(() => hasPermission(PERMISSIONS.ASSIGN));
// options shown in the select
const options = ref([] as FG.Service[]);
// users which are available (e.g. not already assigned)
const freeUsers = computed(() =>
userStore.users.filter((u) => props.modelValue.services.every((s) => s.userid !== u.userid))
);
// used to filter options based on user input
function filterUsers(
input: string,
doneFn: (
callbackFn: () => void,
afterFn?: (ref: { [index: string]: unknown }) => void
) => void,
abortFn: () => void
) {
if (freeUsers.value.length == 0) return abortFn();
// Filter the options
doneFn(() => {
// Skip filter options if input is too short
if (!input || input.length < 2) {
options.value = freeUsers.value.map<FG.Service>((u) => ({
userid: u.userid,
value: 1,
is_backup: false,
}));
return;
}
// Search matching string within all names
options.value = freeUsers.value
.filter((u) =>
input
.toLowerCase()
.split(' ')
.every(
(needle) =>
u.display_name.toLowerCase().indexOf(needle) > -1 ||
u.firstname.toLowerCase().indexOf(needle) > -1 ||
u.lastname.toLowerCase().indexOf(needle) > -1
)
)
.map<FG.Service>((u) => ({ userid: u.userid, value: 1, is_backup: false }));
});
}
return {
assignJob,
assign,
unassign: (s?: FG.Service) => assign(Object.assign({}, s || service.value, { value: -1 })),
backup: (is_backup: boolean) => assign(Object.assign({}, service.value, { is_backup })),
canAssignOther,
canAssign,
canInvite,
filterUsers,
isBackup,
isEnrolled,
isFull,
invite: () => invite(true),
transfer: () => invite(false),
typeName,
userDisplay,
options,
asHour,
};
},

View File

@ -0,0 +1,67 @@
<template>
<q-chip
:removable="removeable"
:color="modelValue.is_backup ? 'grey' : undefined"
@remove="remove"
>
<q-tooltip>{{ displayName }} ({{ serviceValue }}x)</q-tooltip>
<user-avatar :model-value="modelValue.userid">
<slot v-if="modelValue.is_backup">
<q-icon name="mdi-eye-off" />
</slot>
</user-avatar>
<div class="ellipsis">{{ displayName }}</div>
<q-badge v-if="modelValue.value !== 1" :label="serviceValue" style="margin-left: 0.25em" />
<slot />
</q-chip>
</template>
<script lang="ts">
import { useUserStore } from '@flaschengeist/api';
import { PropType, computed, defineComponent, onBeforeMount, ref, watch } from 'vue';
import { UserAvatar } from '@flaschengeist/api/components';
export default defineComponent({
name: 'ServiceUserChip',
components: { UserAvatar },
props: {
modelValue: {
type: Object as PropType<FG.Service>,
required: true,
},
removeable: {
type: Boolean,
default: false,
},
},
emits: ['remove'],
setup(props, { emit }) {
const userStore = useUserStore();
const user = ref<FG.User>();
onBeforeMount(async () => {
user.value = await userStore.getUser(props.modelValue.userid);
});
watch(
() => props.modelValue,
async () => (user.value = await userStore.getUser(props.modelValue.userid))
);
const displayName = computed(() => user.value?.display_name || '...');
const serviceValue = computed(() =>
props.modelValue.value.toFixed(Number.isInteger(props.modelValue.value) ? 0 : 1)
);
return {
displayName,
remove: () => emit('remove', props.modelValue),
serviceValue,
};
},
});
</script>
<style></style>

View File

@ -13,7 +13,8 @@
filled
:options="otherUsers"
:option-label="(opt) => opt.display_name"
:multiple="isInvite"
:option-disable="(opt) => !isInvite && invitees.length > 0 && opt != invitees[0]"
multiple
use-chips
stack-label
label="Dienste"
@ -22,7 +23,7 @@
</q-card-section>
<!-- buttons example -->
<q-card-actions align="right">
<q-btn color="primary" label="Ok" @click="invite" />
<q-btn color="primary" label="Ok" :disable="invitees.length === 0" @click="invite" />
<q-btn color="primary" label="Abbrechen" @click="onDialogCancel" />
</q-card-actions>
</q-card>
@ -80,4 +81,4 @@ export default defineComponent({
};
},
});
</script>
</script>

View File

@ -10,8 +10,11 @@ const EventTypes = {
invite: 0x01,
transfer: 0x02,
invitation_response: 0x10,
invitation_accepted: 0x10,
invitation_rejected: 0x11,
invitation_accepted: 0x11,
invitation_rejected: 0x12,
info: 0x20,
info_accepted: 0x21,
info_rejected: 0x22,
};
function transpile(msg: FG_Plugin.Notification) {
@ -29,7 +32,10 @@ function transpile(msg: FG_Plugin.Notification) {
};
message.link = { name: 'events-requests' };
} else if ((message.data.type & EventTypes._mask_) === EventTypes.invitation_response) {
} else if (
(message.data.type & EventTypes._mask_) === EventTypes.invitation_response ||
(message.data.type & EventTypes._mask_) === EventTypes.info
) {
message.link = {
name: 'events-single-view',
params: { id: (<InvitationResponseData>message.data).event },
@ -39,7 +45,7 @@ function transpile(msg: FG_Plugin.Notification) {
}
const plugin: FG_Plugin.Plugin = {
id: 'dev.flaschengeist.events',
id: 'events',
name: 'Event schedule',
innerRoutes,
internalRoutes: privateRoutes,

View File

@ -44,7 +44,8 @@
</template>
<script lang="ts">
import { computed, defineComponent, ref } from 'vue';
import { computed, defineComponent, ref, onBeforeMount, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import ManageTypes from '../components/management/ManageTypes.vue';
import EditEvent from '../components/management/EditEvent.vue';
import { hasPermission } from '@flaschengeist/api';
@ -70,6 +71,21 @@ export default defineComponent({
: []),
]);
const route = useRoute();
const router = useRouter();
onBeforeMount(async () => {
if (
(route.query.q_tab && route.query.q_tab === 'create') ||
route.query.q_tab === 'jobtypes' ||
route.query.q_tab === 'eventtypes'
) {
tab.value = route.query.q_tab;
} else {
await router.replace({ query: { q_tab: tab.value } });
}
});
const drawer = ref<boolean>(false);
const tab = ref<string>('create');
@ -82,6 +98,10 @@ export default defineComponent({
},
});
watch(tab, async (val) => {
await router.replace({ query: { q_tab: val } });
});
return {
showDrawer,
tab,

View File

@ -43,21 +43,39 @@
</template>
<script lang="ts">
import { computed, defineComponent, ref } from 'vue';
import { computed, defineComponent, ref, onBeforeMount, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useQuasar } from 'quasar';
import AgendaView from '../components/overview/AgendaView.vue';
import ListView from '../components/overview/ListView.vue';
import { useEventStore } from '../store';
export default defineComponent({
name: 'EventOverview',
components: { AgendaView, ListView },
setup() {
const store = useEventStore();
const route = useRoute();
const router = useRouter();
const tab = ref<string>('agendaView');
onBeforeMount(async () => {
void store.getJobTypes();
if (
Object.keys(route.query).includes('q_tab') &&
(route.query.q_tab === 'listView' || route.query.q_tab === 'agendaView')
) {
tab.value = route.query.q_tab as string;
} else {
await router.replace({ query: { ...route.query, q_tab: tab.value } });
}
});
const quasar = useQuasar();
const tabs = computed(() => ([
const tabs = computed(() => [
{ name: 'listView', label: 'Liste' },
{ name: 'agendaView', label: 'Kalendar' }
]));
{ name: 'agendaView', label: 'Kalendar' },
]);
const drawer = ref<boolean>(false);
@ -68,7 +86,10 @@ export default defineComponent({
},
});
const tab = ref<string>('agendaView');
watch(tab, async (val) => {
console.log(val);
await router.replace({ query: { ...route.query, q_tab: val } });
});
return {
showDrawer,

View File

@ -1,8 +1,320 @@
<template>
<q-page padding>
<q-card>
<q-card-section class="row"> </q-card-section>
<q-card-section> </q-card-section>
</q-card>
<q-table
v-model:pagination="pagination"
title="Dienstanfragen"
:rows="rows"
:columns="columns"
row-key="id"
:loading="loading"
binary-state-sort
@request="onRequest"
>
<template #top-right>
<q-toggle v-model="showSent" dense label="Gesendete anzeigen" />
</template>
<template #body-cell-inviter="props">
<q-td :props="props">
<div>
{{ props.value.with
}}<q-icon v-if="props.value.sender" name="mdi-account-alert">
<q-tooltip>Gesendet von {{ props.value.sender }}</q-tooltip>
</q-icon>
</div>
</q-td>
</template>
<template #body-cell-type="props">
<q-td :props="props">
<q-icon size="sm" :name="types[props.value].icon">
<q-tooltip>{{ types[props.value].tooltip }}</q-tooltip>
</q-icon>
</q-td>
</template>
<template #body-cell-actions="props">
<q-td :props="props">
<!-- <q-btn v-for="action in props.value" :key="action.icon" :icon="action.icon" dense /> -->
<div class="row justify-end">
<div v-for="action in props.value" :key="action.icon">
<q-btn
class="q-mx-xs"
:icon="action.icon"
dense
@click="action.onClick"
round
:color="action.color"
>
<q-tooltip>{{ action.tooltip }}</q-tooltip>
</q-btn>
</div>
</div>
</q-td>
</template>
</q-table>
</q-page>
</template>
<script lang="ts">
import { formatStartEnd, useMainStore, useUserStore } from '@flaschengeist/api';
import { computed, defineComponent, ref, onBeforeMount, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { QTableProps } from 'quasar';
import { Job } from '../store/models';
import { useEventStore } from '../store';
import { EventNotification, InvitationData, InvitationResponseData } from '../events';
export default defineComponent({
name: 'PageEventRequests',
setup() {
const store = useEventStore();
const userStore = useUserStore();
const mainStore = useMainStore();
const router = useRouter();
const route = useRoute();
interface RowData extends FG.Invitation {
inviter: FG.User;
invitee: FG.User;
transferee?: FG.User;
job: Job;
}
// Generated data used for the table
const rows = ref([] as RowData[]);
// Loading state for data generation (getting Job information)
const loading = ref(false);
// Which "page" of invitations to show (we load all, but do not fetch all jobs)
const pagination = ref({
sortBy: 'desc',
descending: false,
page: 1,
rowsPerPage: 3,
rowsNumber: 4,
});
// Real invitations
const invitations = computed(() =>
showSent.value
? store.invitations
: store.invitations.filter((i) => i.inviter_id !== mainStore.currentUser.userid)
);
const all_notifications = computed<EventNotification[]>(() => {
return mainStore.notifications.filter((n) => n.plugin === 'events') as EventNotification[];
});
const showSent = ref(false);
async function fillRows(data: FG.Invitation[]) {
const res = [] as RowData[];
for (let i = 0; i < data.length; ++i) {
res.push(
Object.assign({}, data[i], {
inviter: <FG.User>await userStore.getUser(data[i].inviter_id),
invitee: <FG.User>await userStore.getUser(data[i].invitee_id),
transferee: data[i].transferee_id
? await userStore.getUser(<string>data[i].transferee_id)
: undefined,
job: new Job(await store.getJob(data[i].job_id)),
})
);
}
rows.value = res;
}
type onRequestType = QTableProps['onRequest'];
const onRequest: onRequestType = (requestProp) => {
const { page, rowsPerPage, sortBy, descending } = requestProp.pagination;
loading.value = true;
// Number of total invitations
pagination.value.rowsNumber = invitations.value.length;
// calculate starting row of data
const startRow = (page - 1) * rowsPerPage;
// get all rows if "All" (0) is selected
const fetchCount =
rowsPerPage === 0
? pagination.value.rowsNumber
: Math.min(pagination.value.rowsNumber - startRow, rowsPerPage);
// copy array, as sort is in-place
function sorting<T = any>(key: string | keyof T, descending = true) {
return (a: T, b: T) => {
const v1 = a[key as keyof T];
if (v1 === undefined) return descending ? -1 : 1;
const v2 = b[key as keyof T];
if (v2 === undefined) return descending ? 1 : -1;
return (v1 < v2 ? -1 : 1) * (descending ? -1 : 1);
};
}
// Set table data
fillRows(
[...invitations.value]
.sort(sorting(sortBy, descending))
.slice(startRow, startRow + fetchCount)
)
.then(() => {
pagination.value.page = page;
pagination.value.rowsPerPage = rowsPerPage;
pagination.value.sortBy = sortBy;
pagination.value.descending = descending;
})
.finally(() => (loading.value = false));
};
onBeforeMount(async () => {
if (route.query.sent === 'true') {
showSent.value = true;
}
void Promise.allSettled([
userStore.getUsers(),
store.getInvitations(),
store.getJobTypes(),
]).then(() =>
onRequest({ pagination: pagination.value, filter: () => [], getCellValue: () => [] })
);
});
watch(showSent, async () => {
onRequest({ pagination: pagination.value, filter: () => [], getCellValue: () => [] });
await router.replace({ query: { sent: showSent.value ? 'true' : 'false' } });
});
function getType(row: RowData) {
var idx = row.transferee === undefined ? 0 : 1;
if (row.inviter.userid === mainStore.currentUser.userid) idx += 2;
return idx;
}
const dimmed = (row: RowData) => (getType(row) >= types.length / 2 ? 'dimmed' : undefined);
const columns = [
{
label: 'Type',
name: 'type',
align: 'left',
field: getType,
sortable: true,
classes: dimmed,
},
{
label: 'Dienstart',
align: 'left',
name: 'job_type',
sortable: true,
classes: dimmed,
field: (row: RowData) =>
store.jobTypes.find((t) => t.id == row.job.type)?.name || 'Unbekannt',
},
{
label: 'Wann',
align: 'center',
sortable: true,
name: 'job_start',
classes: dimmed,
field: (row: RowData) => formatStartEnd(row.job.start, row.job.end) + ' Uhr',
},
{
label: 'Von / Mit',
name: 'inviter',
align: 'center',
classes: dimmed,
field: (row: RowData) => {
const sender =
row.transferee_id && row.transferee_id !== row.inviter_id
? row.inviter.display_name
: undefined;
if (row.invitee_id === mainStore.currentUser.userid) {
return {
with: row.transferee ? row.transferee.display_name : row.inviter.display_name,
sender,
};
}
if (row.transferee_id === mainStore.currentUser.userid) {
return {
with: row.invitee.display_name,
sender,
};
}
return {
with: !row.transferee
? row.invitee.display_name
: `${row.transferee.display_name} <-> ${row.invitee.display_name}`,
};
},
},
{
label: 'Aktionen',
align: 'right',
name: 'actions',
classes: dimmed,
field: (row: RowData) => {
const sender = row.inviter_id === mainStore.currentUser.userid;
let actions = [];
const reject = {
icon: 'mdi-delete',
tooltip: 'Einladung löschen',
color: 'negative',
onClick: () => {
void store.rejectInvitation(row.id).then(() => {
onRequest({
pagination: pagination.value,
filter: () => [],
getCellValue: () => [],
});
const notification = all_notifications.value.find(
(n) => (<InvitationData>n.data).invitation === row.id
);
if (notification !== undefined) {
void mainStore.removeNotification(notification.id);
}
});
},
};
const accept = {
icon: 'mdi-check',
tooltip: 'Einladung annehmen',
color: 'primary',
onClick: () => {
void store.acceptInvitation(row.id).then(() => {
onRequest({
pagination: pagination.value,
filter: () => [],
getCellValue: () => [],
});
const notification = all_notifications.value.find(
(n) => (<InvitationData>n.data).invitation === row.id
);
if (notification !== undefined) {
void mainStore.removeNotification(notification.id);
}
});
},
};
if (sender) {
actions.push(reject);
} else if (row.invitee_id === mainStore.currentUser.userid) {
actions.push(accept);
actions.push({ ...reject, icon: 'mdi-close' });
}
return actions;
},
},
];
const types = [
{ icon: 'mdi-calendar', tooltip: 'Einladung' },
{ icon: 'mdi-calendar-sync', tooltip: 'Tauschanfrage' },
{ icon: 'mdi-calendar-outline', tooltip: 'Einladung (von dir)' },
{ icon: 'mdi-calendar-sync-outline', tooltip: 'Tauschanfrage (von dir)' },
];
return {
columns,
loading,
onRequest,
pagination,
rows,
showSent,
types,
};
},
});
</script>

View File

@ -22,6 +22,16 @@ export const innerRoutes: FG_Plugin.MenuRoute[] = [
component: () => import('../pages/EventOverview.vue'),
},
},
{
title: 'Dienstanfragen',
icon: 'mdi-account-switch',
shortcut: false,
route: {
path: 'events-requests',
name: 'events-requests',
component: () => import('../pages/EventRequests.vue'),
},
},
{
title: 'Dienstverwaltung',
icon: 'mdi-account-details',
@ -34,16 +44,6 @@ export const innerRoutes: FG_Plugin.MenuRoute[] = [
props: (route) => ({ date: route.query.date }),
},
},
{
title: 'Dienstanfragen',
icon: 'mdi-account-switch',
shortcut: false,
route: {
path: 'events-requests',
name: 'events-requests',
component: () => import('../pages/EventRequests.vue'),
},
},
],
},
];

View File

@ -1,6 +1,7 @@
import { api, isAxiosError } from '@flaschengeist/api';
import { defineStore } from 'pinia';
import { EditableEvent } from './models';
import { Notify } from 'quasar';
/**
* Convert JSON decoded Job to real job (fix Date object)
@ -28,6 +29,7 @@ export const useEventStore = defineStore({
jobTypes: [] as FG.JobType[],
eventTypes: [] as FG.EventType[],
templates: [] as FG.Event[],
invitations: [] as FG.Invitation[],
}),
getters: {},
@ -145,19 +147,16 @@ export const useEventStore = defineStore({
},
async addEvent(event: EditableEvent) {
console.log('addEvent', event);
if (event?.id === undefined) {
const { data } = await api.post<FG.Event>('/events', event);
if (data.is_template) this.templates.push(data);
fixEvent(data);
return data;
} else {
if (typeof event.type === 'object') event.type = event.type.id;
const { data } = await api.put<FG.Event>(
`/events/${event.id}`,
Object.assign(event, { jobs: undefined })
);
const { data } = await api.put<FG.Event>(`/events/${event.id}`, event);
if (data.is_template) this.templates.push(data);
fixEvent(data);
return data;
}
},
@ -168,6 +167,10 @@ export const useEventStore = defineStore({
.then(({ data }) => fixJob(data));
},
async getJob(id: number) {
return api.get<FG.Job>(`/events/jobs/${id}`).then(({ data }) => fixJob(data));
},
async getJobs(filter?: FG.PaginationFilter) {
return api
.get<FG.PaginationResponse<FG.Job>>('/events/jobs', { params: <unknown>filter })
@ -187,18 +190,60 @@ export const useEventStore = defineStore({
return api.post<FG.Invitation[]>('/events/invitations', {
job: job.id,
invitees: invitees.map((v) => v.userid),
transferee: transferee,
transferee: transferee?.userid,
});
},
async getInvitations(force = false) {
if (this.invitations.length == 0 || force) {
const { data } = await api.get<FG.Invitation[]>('/events/invitations');
this.invitations = data;
}
return this.invitations;
},
async rejectInvitation(invite: FG.Invitation | number) {
return api.delete(`/events/invitations/${typeof invite === 'number' ? invite : invite.id}`);
try {
await api.delete(`/events/invitations/${typeof invite === 'number' ? invite : invite.id}`);
const idx = this.invitations.findIndex((v) => v.id === (invite.id || invite));
if (idx >= 0) this.invitations.splice(idx, 1);
notify_success('Einladung für erfolgreich abgelehnt');
} catch (e) {
notify_failure();
}
},
async acceptInvitation(invite: FG.Invitation | number) {
return api.put(`/events/invitations/${typeof invite === 'number' ? invite : invite.id}`, {
accept: true,
});
try {
await api.put(`/events/invitations/${typeof invite === 'number' ? invite : invite.id}`, {
accept: true,
});
const idx = this.invitations.findIndex((v) => v.id === (invite.id || invite));
if (idx >= 0) this.invitations.splice(idx, 1);
notify_success('Einladung für erfolgreich angenommen');
} catch (e) {
notify_failure();
}
},
},
});
function notify_failure() {
Notify.create({
message: 'Es ist ein Fehler aufgetreten.',
color: 'negative',
group: false,
timeout: 10000,
actions: [{ icon: 'mdi-close', color: 'white' }],
});
}
function notify_success(msg: string) {
Notify.create({
message: msg,
color: 'positive',
group: false,
timeout: 5000,
actions: [{ icon: 'mdi-close', color: 'white' }],
});
}

View File

@ -4,32 +4,76 @@ import { date } from 'quasar';
export type EditableEvent = Omit<Omit<Omit<FG.Event, 'jobs'>, 'type'>, 'id'> & {
type?: FG.EventType | number;
id?: number;
jobs: EditableJob[];
jobs: Job[];
};
/** A new job does not have an id or type assigned */
export type EditableJob = Omit<Omit<FG.Job, 'type'>, 'id'> & {
type?: FG.EventType | number;
id?: number;
};
export class Job implements FG.Job {
id = NaN;
start: Date;
end?: Date = undefined;
type: FG.JobType | number = NaN;
comment?: string = undefined;
locked = false;
services = [] as FG.Service[];
required_services = 0;
export function emptyJob(startDate = new Date()): EditableJob {
const start = date.adjustDate(startDate, {
hours: new Date().getHours(),
});
return {
start: start,
end: date.addToDate(start, { hours: 1 }),
services: [],
locked: false,
required_services: 2,
};
/**
* Build Job from API Job interface
* @param iJob Object following the API Job interface
*/
constructor(iJob?: Partial<FG.Job>) {
if (!iJob || iJob.start === undefined)
this.start = date.buildDate({
hours: new Date().getHours(),
minutes: 0,
seconds: 0,
milliseconds: 0,
});
else this.start = new Date(); // <-- make TS happy "no initalizer"
if (!iJob || iJob.end === undefined) {
this.end = date.buildDate({
hours: new Date().getHours() + 4,
minutes: 0,
seconds: 0,
milliseconds: 0,
});
}
if (iJob !== undefined) Object.assign(this, iJob);
}
/**
* Create Job from start Date
* @param start when does the event start?
* @param adjustTime Set hours to current value, zero minutes and seconds
* @param duration How long should the job go? Value in hours or undefined
* @returns new Job event
*/
static fromDate(start: Date, adjustTime = true, duration?: number) {
if (adjustTime)
start = date.adjustDate(start, {
hours: new Date().getHours(),
minutes: 0,
seconds: 0,
milliseconds: 0,
});
return new Job({
start: start,
end: duration === undefined ? undefined : date.addToDate(start, { hours: duration }),
});
}
/**
* Check if this instance was loaded from API
*/
isPersistent() {
return !isNaN(this.id);
}
}
export function emptyEvent(startDate: Date = new Date()): EditableEvent {
return {
start: date.adjustDate(startDate, { hours: 0, minutes: 0, seconds: 0, milliseconds: 0 }),
jobs: [emptyJob(startDate)],
jobs: [Job.fromDate(startDate, true, 4)],
is_template: false,
};
}

View File

@ -1,5 +1,5 @@
{
"extends": "@quasar/app/tsconfig-preset",
"extends": "@quasar/app-webpack/tsconfig-preset",
"target": "esnext",
"compilerOptions": {
"baseUrl": "./src/",