Compare commits

...

19 Commits

Author SHA1 Message Date
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
13 changed files with 599 additions and 141 deletions

4
.gitignore vendored
View File

@ -7,3 +7,7 @@ yarn.lock
# Backend # Backend
*.egg-info *.egg-info
__pycache__ __pycache__
# IDE
.idea
*.swp

View File

@ -14,10 +14,9 @@ __version__ = pkg_resources.get_distribution("flaschengeist_events").version
class EventPlugin(Plugin): class EventPlugin(Plugin):
#id = "dev.flaschengeist.events" # id = "dev.flaschengeist.events"
#plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][EventPlugin.id])
# provided resources # provided resources
#permissions = permissions.permissions # permissions = permissions.permissions
models = models models = models
# def __init__(self, cfg): # def __init__(self, cfg):
@ -27,7 +26,12 @@ class EventPlugin(Plugin):
def load(self): def load(self):
from .routes import blueprint from .routes import blueprint
self.blueprint = blueprint self.blueprint = blueprint
def install(self): def install(self):
self.install_permissions(permissions.permissions) self.install_permissions(permissions.permissions)
@staticmethod
def getPlugin() -> LocalProxy["EventPlugin"]:
return LocalProxy(lambda: current_app.config["FG_PLUGINS"]["events"])

View File

@ -1,7 +1,8 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from enum import IntEnum from enum import IntEnum
from typing import Optional, Tuple from typing import Optional, Tuple, Union
from flaschengeist.models import UtcDateTime from flaschengeist.controller import userController
from flaschengeist.models import Notification, UtcDateTime
from flaschengeist.models.user import User from flaschengeist.models.user import User
from werkzeug.exceptions import BadRequest, Conflict, NotFound from werkzeug.exceptions import BadRequest, Conflict, NotFound
@ -27,8 +28,11 @@ class NotifyType(IntEnum):
INVITE = 0x01 INVITE = 0x01
TRANSFER = 0x02 TRANSFER = 0x02
# Invitation responsed 0x10..0x1F # Invitation responsed 0x10..0x1F
INVITATION_ACCEPTED = 0x10 INVITATION_ACCEPTED = 0x11
INVITATION_REJECTED = 0x11 INVITATION_REJECTED = 0x12
# Information responses 0x20..0x2F
INFO_ACCEPTED = 0x21
INFO_REJECTED = 0x22
@before_delete_user @before_delete_user
@ -295,7 +299,7 @@ def delete_job(job: Job):
db.session.commit() db.session.commit()
def assign_job(job: Job, user, value, is_backup=False): def assign_job(job: Job, user, value, is_backup=False, notify=False):
assert value > 0 assert value > 0
service = Service.query.get((job.id, user.id_)) service = Service.query.get((job.id, user.id_))
if service: if service:
@ -303,10 +307,17 @@ def assign_job(job: Job, user, value, is_backup=False):
service.is_backup = is_backup service.is_backup = is_backup
else: else:
job.services.append(Service(user_=user, value=value, is_backup=is_backup, job_=job)) 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() db.session.commit()
def unassign_job(job: Job = None, user=None, service=None, notify=False): def unassign_job(job: Job = None, user=None, service=None, notify=False):
_date = job.start.strftime("%d.%m.%Y")
if service is None: if service is None:
assert job is not None and user is not None assert job is not None and user is not None
service = Service.query.get((job.id, user.id_)) service = Service.query.get((job.id, user.id_))
@ -320,17 +331,24 @@ def unassign_job(job: Job = None, user=None, service=None, notify=False):
db.session.delete(service) db.session.delete(service)
db.session.commit() db.session.commit()
if notify: if notify:
EventPlugin.plugin.notify(user, "Your assignmet was cancelled", {"event_id": event_id}) 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): def invite(job: Job, invitee, inviter, transferee=None):
inv = Invitation(job_=job, inviter_=inviter, invitee_=invitee, transferee_=transferee) inv = Invitation(job_=job, inviter_=inviter, invitee_=invitee, transferee_=transferee)
db.session.add(inv) db.session.add(inv)
update() update()
_date = job.start.strftime("%d.%m.%Y")
if transferee is None: if transferee is None:
EventPlugin.plugin.notify(invitee, _("Job invitation"), {"type": NotifyType.INVITE, "invitation": inv.id}) EventPlugin.getPlugin().notify(
invitee, _(f"Job invitation\n{_date}"), {"type": NotifyType.INVITE, "invitation": inv.id}
)
else: else:
EventPlugin.plugin.notify(invitee, _("Job transfer"), {"type": NotifyType.TRANSFER, "invitation": inv.id}) EventPlugin.getPlugin().notify(
invitee, _(f"Job transfer\n{_date}"), {"type": NotifyType.TRANSFER, "invitation": inv.id}
)
return inv return inv
@ -347,9 +365,21 @@ def get_invitations(user: User):
).all() ).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): def cancel_invitation(inv: Invitation):
db.session.delete(inv) db.session.delete(inv)
db.session.commit() db.session.commit()
cleanup_notifications(inv)
def respond_invitation(invite: Invitation, accepted=True): def respond_invitation(invite: Invitation, accepted=True):
@ -362,7 +392,7 @@ def respond_invitation(invite: Invitation, accepted=True):
raise Conflict raise Conflict
if not accepted: if not accepted:
EventPlugin.plugin.notify( EventPlugin.getPlugin().notify(
inviter, inviter,
_("Invitation rejected"), _("Invitation rejected"),
{ {
@ -376,12 +406,12 @@ def respond_invitation(invite: Invitation, accepted=True):
if invite.transferee_id is None: if invite.transferee_id is None:
assign_job(job, invite.invitee_, 1) assign_job(job, invite.invitee_, 1)
else: else:
service = filter(lambda s: s.userid == invite.transferee_id, job.services) service = tuple(filter(lambda s: s.userid == invite.transferee_id, job.services))
if not service: if not service:
raise Conflict raise Conflict
unassign_job(job, invite.transferee_, service[0], True) unassign_job(job, invite.transferee_, service[0], True)
assign_job(job, invite.invitee_, service[0].value) assign_job(job, invite.invitee_, service[0].value)
EventPlugin.plugin.notify( EventPlugin.getPlugin().notify(
inviter, inviter,
_("Invitation accepted"), _("Invitation accepted"),
{ {
@ -402,7 +432,7 @@ def assign_backups():
services = Service.query.filter(Service.is_backup == True).join(Job).filter(Job.start <= start).all() services = Service.query.filter(Service.is_backup == True).join(Job).filter(Job.start <= start).all()
for service in services: for service in services:
if service.job_.start <= now or service.job_.is_full(): if service.job_.start <= now or service.job_.is_full():
EventPlugin.plugin.notify( EventPlugin.getPlugin().notify(
service.user_, service.user_,
"Your backup assignment was cancelled.", "Your backup assignment was cancelled.",
{"event_id": service.job_.event_id_}, {"event_id": service.job_.event_id_},
@ -412,7 +442,7 @@ def assign_backups():
else: else:
service.is_backup = False service.is_backup = False
logger.debug(f"Service not full, assigning backup. {service.serialize()}") logger.debug(f"Service not full, assigning backup. {service.serialize()}")
EventPlugin.plugin.notify( EventPlugin.getPlugin().notify(
service.user_, service.user_,
"Your backup assignment was accepted.", "Your backup assignment was accepted.",
{"event_id": service.job_.event_id_}, {"event_id": service.job_.event_id_},

View File

@ -11,82 +11,95 @@ import flaschengeist
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = 'e70508bd8cb4' revision = "e70508bd8cb4"
down_revision = None down_revision = None
branch_labels = ('events',) branch_labels = ("events",)
depends_on = "flaschengeist" depends_on = "flaschengeist"
def upgrade(): def upgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table('events_event_type', op.create_table(
sa.Column('id', flaschengeist.database.types.Serial(), nullable=False), "events_event_type",
sa.Column('name', sa.String(length=30), nullable=False), sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_events_event_type')), sa.Column("name", sa.String(length=30), nullable=False),
sa.UniqueConstraint('name', name=op.f('uq_events_event_type_name')) 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', op.create_table(
sa.Column('id', flaschengeist.database.types.Serial(), nullable=False), "events_job_type",
sa.Column('name', sa.String(length=30), nullable=False), sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_events_job_type')), sa.Column("name", sa.String(length=30), nullable=False),
sa.UniqueConstraint('name', name=op.f('uq_events_job_type_name')) 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', op.create_table(
sa.Column('id', flaschengeist.database.types.Serial(), nullable=False), "events_event",
sa.Column('start', flaschengeist.database.types.UtcDateTime(), nullable=False), sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column('end', flaschengeist.database.types.UtcDateTime(), nullable=True), sa.Column("start", flaschengeist.database.types.UtcDateTime(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=True), sa.Column("end", flaschengeist.database.types.UtcDateTime(), nullable=True),
sa.Column('description', sa.String(length=512), nullable=True), sa.Column("name", sa.String(length=255), nullable=True),
sa.Column('is_template', sa.Boolean(), nullable=True), sa.Column("description", sa.String(length=512), nullable=True),
sa.Column('type_id', flaschengeist.database.types.Serial(), nullable=False), sa.Column("is_template", sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['type_id'], ['events_event_type.id'], name=op.f('fk_events_event_type_id_events_event_type'), ondelete='CASCADE'), sa.Column("type_id", flaschengeist.database.types.Serial(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_events_event')) 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', op.create_table(
sa.Column('id', flaschengeist.database.types.Serial(), nullable=False), "events_job",
sa.Column('start', flaschengeist.database.types.UtcDateTime(), nullable=False), sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column('end', flaschengeist.database.types.UtcDateTime(), nullable=True), sa.Column("start", flaschengeist.database.types.UtcDateTime(), nullable=False),
sa.Column('comment', sa.String(length=256), nullable=True), sa.Column("end", flaschengeist.database.types.UtcDateTime(), nullable=True),
sa.Column('type_id', flaschengeist.database.types.Serial(), nullable=False), sa.Column("comment", sa.String(length=256), nullable=True),
sa.Column('locked', sa.Boolean(), nullable=False), sa.Column("type_id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column('required_services', sa.Numeric(precision=4, scale=2, asdecimal=False), nullable=False), sa.Column("locked", sa.Boolean(), nullable=False),
sa.Column('event_id', flaschengeist.database.types.Serial(), nullable=False), sa.Column("required_services", sa.Numeric(precision=4, scale=2, asdecimal=False), nullable=False),
sa.ForeignKeyConstraint(['event_id'], ['events_event.id'], name=op.f('fk_events_job_event_id_events_event')), sa.Column("event_id", flaschengeist.database.types.Serial(), nullable=False),
sa.ForeignKeyConstraint(['type_id'], ['events_job_type.id'], name=op.f('fk_events_job_type_id_events_job_type')), sa.ForeignKeyConstraint(["event_id"], ["events_event.id"], name=op.f("fk_events_job_event_id_events_event")),
sa.PrimaryKeyConstraint('id', name=op.f('pk_events_job')), sa.ForeignKeyConstraint(
sa.UniqueConstraint('type_id', 'start', 'event_id', name='_type_start_uc') ["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', op.create_table(
sa.Column('id', flaschengeist.database.types.Serial(), nullable=False), "events_invitation",
sa.Column('time', flaschengeist.database.types.UtcDateTime(), nullable=False), sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column('job_id', flaschengeist.database.types.Serial(), nullable=False), sa.Column("time", flaschengeist.database.types.UtcDateTime(), nullable=False),
sa.Column('invitee_id', flaschengeist.database.types.Serial(), nullable=False), sa.Column("job_id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column('inviter_id', flaschengeist.database.types.Serial(), nullable=False), sa.Column("invitee_id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column('transferee_id', flaschengeist.database.types.Serial(), nullable=True), sa.Column("inviter_id", flaschengeist.database.types.Serial(), nullable=False),
sa.ForeignKeyConstraint(['invitee_id'], ['user.id'], name=op.f('fk_events_invitation_invitee_id_user')), sa.Column("transferee_id", flaschengeist.database.types.Serial(), nullable=True),
sa.ForeignKeyConstraint(['inviter_id'], ['user.id'], name=op.f('fk_events_invitation_inviter_id_user')), sa.ForeignKeyConstraint(["invitee_id"], ["user.id"], name=op.f("fk_events_invitation_invitee_id_user")),
sa.ForeignKeyConstraint(['job_id'], ['events_job.id'], name=op.f('fk_events_invitation_job_id_events_job')), sa.ForeignKeyConstraint(["inviter_id"], ["user.id"], name=op.f("fk_events_invitation_inviter_id_user")),
sa.ForeignKeyConstraint(['transferee_id'], ['user.id'], name=op.f('fk_events_invitation_transferee_id_user')), sa.ForeignKeyConstraint(["job_id"], ["events_job.id"], name=op.f("fk_events_invitation_job_id_events_job")),
sa.PrimaryKeyConstraint('id', name=op.f('pk_events_invitation')) 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', op.create_table(
sa.Column('is_backup', sa.Boolean(), nullable=True), "events_service",
sa.Column('value', sa.Numeric(precision=3, scale=2, asdecimal=False), nullable=False), sa.Column("is_backup", sa.Boolean(), nullable=True),
sa.Column('job_id', flaschengeist.database.types.Serial(), nullable=False), sa.Column("value", sa.Numeric(precision=3, scale=2, asdecimal=False), nullable=False),
sa.Column('user_id', flaschengeist.database.types.Serial(), nullable=False), sa.Column("job_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.Column("user_id", flaschengeist.database.types.Serial(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_events_service_user_id_user')), sa.ForeignKeyConstraint(["job_id"], ["events_job.id"], name=op.f("fk_events_service_job_id_events_job")),
sa.PrimaryKeyConstraint('job_id', 'user_id', name=op.f('pk_events_service')) 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 ### # ### end Alembic commands ###
def downgrade(): def downgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_table('events_service') op.drop_table("events_service")
op.drop_table('events_invitation') op.drop_table("events_invitation")
op.drop_table('events_job') op.drop_table("events_job")
op.drop_table('events_event') op.drop_table("events_event")
op.drop_table('events_job_type') op.drop_table("events_job_type")
op.drop_table('events_event_type') op.drop_table("events_event_type")
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@ -24,8 +24,9 @@ def dict_get(self, key, default=None, type=None):
if type is not None: if type is not None:
try: try:
rv = type(rv) rv = type(rv)
except ValueError: except (ValueError, TypeError):
rv = default rv = default
return rv return rv
@ -457,7 +458,9 @@ def assign_job(job_id, current_session: Session):
): ):
raise Forbidden raise Forbidden
if value > 0: if value > 0:
event_controller.assign_job(job, user, value, data.get("is_backup", False)) event_controller.assign_job(
job, user, value, data.get("is_backup", False), notify=user != current_session.user_
)
else: else:
event_controller.unassign_job(job, user, notify=user != current_session.user_) event_controller.unassign_job(job, user, notify=user != current_session.user_)
except (TypeError, KeyError, ValueError): except (TypeError, KeyError, ValueError):

View File

@ -112,7 +112,7 @@ import { notEmpty } from '@flaschengeist/api';
import { IsoDateInput } from '@flaschengeist/api/components'; import { IsoDateInput } from '@flaschengeist/api/components';
import { useEventStore } from '../../store'; import { useEventStore } from '../../store';
import { emptyEvent, emptyJob, EditableEvent } from '../../store/models'; import { emptyEvent, Job, EditableEvent } from '../../store/models';
import { date, DateOptions } from 'quasar'; import { date, DateOptions } from 'quasar';
import { computed, defineComponent, PropType, ref, onBeforeMount, watch } from 'vue'; import { computed, defineComponent, PropType, ref, onBeforeMount, watch } from 'vue';
@ -143,7 +143,7 @@ export default defineComponent({
const store = useEventStore(); const store = useEventStore();
const active = ref(0); const active = ref(0);
const activeJob = ref<{ validate: () => Promise<boolean> }>(); const activeJob = ref<{ validate: () => Promise<boolean> }[]>([]);
const templates = computed(() => store.templates); const templates = computed(() => store.templates);
const template = ref<FG.Event>(); const template = ref<FG.Event>();
const event = ref<EditableEvent>(props.modelValue || emptyEvent()); const event = ref<EditableEvent>(props.modelValue || emptyEvent());
@ -166,12 +166,11 @@ export default defineComponent({
function addJob() { function addJob() {
if (!activeJob.value[active.value]) { if (!activeJob.value[active.value]) {
event.value.jobs.push(emptyJob()); event.value.jobs.push(new Job());
active.value = event.value.jobs.length - 1;
} else } else
void activeJob.value[active.value].validate().then((success) => { void activeJob.value[active.value].validate().then((success) => {
if (success) { if (success) {
event.value.jobs.push(emptyJob()); event.value.jobs.push(new Job());
active.value = event.value.jobs.length - 1; active.value = event.value.jobs.length - 1;
} }
}); });

View File

@ -33,7 +33,7 @@
input-debounce="0" input-debounce="0"
class="col-xs-12 col-sm-6 q-pa-sm" class="col-xs-12 col-sm-6 q-pa-sm"
:options="jobtypes" :options="jobtypes"
option-label="name" :option-label="(jobtype) => (typeof jobtype === 'number' ? '' : jobtype.name)"
option-value="id" option-value="id"
map-options map-options
clearable clearable

View File

@ -16,8 +16,8 @@
</div> </div>
</q-card> </q-card>
</q-dialog> </q-dialog>
<div class="q-pa-md"> <!-- <div class="q-pa-md"> -->
<q-card style="height: 70vh; max-width: 1800px" class="q-pa-md"> <!-- <q-card style="height: 70vh; max-width: 1800px" class="q-pa-md"> -->
<div ref="scrollDiv" class="scroll" style="height: 100%"> <div ref="scrollDiv" class="scroll" style="height: 100%">
<q-infinite-scroll :offset="250" @load="load"> <q-infinite-scroll :offset="250" @load="load">
<q-list> <q-list>
@ -26,10 +26,10 @@
</q-item> </q-item>
<template v-for="(events, index) in agendas" :key="index"> <template v-for="(events, index) in agendas" :key="index">
<q-separator /> <q-separator />
<q-item-label overline>{{ index }}</q-item-label> <q-item-label header>{{ asDate(index) }}</q-item-label>
<q-item v-for="(event, idx) in events" :key="idx" <q-item v-for="(event, idx) in events" :key="idx">
><event-slot :model-value="event" />{{ idx }}</q-item <event-slot :model-value="event" />
> </q-item>
</template> </template>
</q-list> </q-list>
<template #loading> <template #loading>
@ -39,8 +39,8 @@
</template> </template>
</q-infinite-scroll> </q-infinite-scroll>
</div> </div>
</q-card> <!-- </q-card> -->
</div> <!-- </div> -->
</template> </template>
<script lang="ts"> <script lang="ts">
@ -157,6 +157,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 { return {
agendas, agendas,
asYear, asYear,
@ -167,6 +176,7 @@ export default defineComponent({
editDone, editDone,
load, load,
remove, remove,
asDate,
}; };
}, },
}); });

View File

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

View File

@ -43,15 +43,21 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, ref } from 'vue'; import { computed, defineComponent, ref, onBeforeMount } from 'vue';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import AgendaView from '../components/overview/AgendaView.vue'; import AgendaView from '../components/overview/AgendaView.vue';
import ListView from '../components/overview/ListView.vue'; import ListView from '../components/overview/ListView.vue';
import { useEventStore } from '../store';
export default defineComponent({ export default defineComponent({
name: 'EventOverview', name: 'EventOverview',
components: { AgendaView, ListView }, components: { AgendaView, ListView },
setup() { setup() {
const store = useEventStore();
onBeforeMount(() => {
void store.getJobTypes();
});
const quasar = useQuasar(); const quasar = useQuasar();
const tabs = computed(() => [ const tabs = computed(() => [

View File

@ -1,8 +1,312 @@
<template> <template>
<q-page padding> <q-page padding>
<q-card> <q-table
<q-card-section class="row"> </q-card-section> v-model:pagination="pagination"
<q-card-section> </q-card-section> title="Dienstanfragen"
</q-card> :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> </q-page>
</template> </template>
<script lang="ts">
import { formatStartEnd, useMainStore, useUserStore } from '@flaschengeist/api';
import { computed, defineComponent, ref, onBeforeMount, watch } from 'vue';
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();
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(() => {
void Promise.allSettled([
userStore.getUsers(),
store.getInvitations(),
store.getJobTypes(),
]).then(() =>
onRequest({ pagination: pagination.value, filter: () => [], getCellValue: () => [] })
);
});
watch(showSent, () => {
onRequest({ pagination: pagination.value, filter: () => [], getCellValue: () => [] });
});
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

@ -1,6 +1,7 @@
import { api, isAxiosError } from '@flaschengeist/api'; import { api, isAxiosError } from '@flaschengeist/api';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { EditableEvent } from './models'; import { EditableEvent } from './models';
import { Notify } from 'quasar';
/** /**
* Convert JSON decoded Job to real job (fix Date object) * Convert JSON decoded Job to real job (fix Date object)
@ -204,13 +205,47 @@ export const useEventStore = defineStore({
}, },
async rejectInvitation(invite: FG.Invitation | number) { 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) { async acceptInvitation(invite: FG.Invitation | number) {
return api.put(`/events/invitations/${typeof invite === 'number' ? invite : invite.id}`, { try {
await api.put(`/events/invitations/${typeof invite === 'number' ? invite : invite.id}`, {
accept: true, 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'> & { export type EditableEvent = Omit<Omit<Omit<FG.Event, 'jobs'>, 'type'>, 'id'> & {
type?: FG.EventType | number; type?: FG.EventType | number;
id?: number; id?: number;
jobs: EditableJob[]; jobs: Job[];
}; };
/** A new job does not have an id or type assigned */ export class Job implements FG.Job {
export type EditableJob = Omit<Omit<FG.Job, 'type'>, 'id'> & { id = NaN;
type?: FG.EventType | number; start: Date;
id?: number; 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, { * 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(), hours: new Date().getHours(),
minutes: 0,
seconds: 0,
milliseconds: 0,
}); });
return { 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, start: start,
end: date.addToDate(start, { hours: 1 }), end: duration === undefined ? undefined : date.addToDate(start, { hours: duration }),
services: [], });
locked: false, }
required_services: 2,
}; /**
* Check if this instance was loaded from API
*/
isPersistent() {
return !isNaN(this.id);
}
} }
export function emptyEvent(startDate: Date = new Date()): EditableEvent { export function emptyEvent(startDate: Date = new Date()): EditableEvent {
return { return {
start: date.adjustDate(startDate, { hours: 0, minutes: 0, seconds: 0, milliseconds: 0 }), start: date.adjustDate(startDate, { hours: 0, minutes: 0, seconds: 0, milliseconds: 0 }),
jobs: [emptyJob(startDate)], jobs: [Job.fromDate(startDate, true, 4)],
is_template: false, is_template: false,
}; };
} }