11 Commits

Author SHA1 Message Date
dae5cb06e7 fix rebase 2024-11-27 15:40:25 +01:00
113828f9b6 refactor and corrections for PR 2024-11-27 15:00:10 +01:00
203b5d88ac Fix missing current permanency display, delegate slot label config to locales 2024-11-27 15:00:07 +01:00
9206fed4ce Implemented locales + previous weeks in time graph 2024-11-27 15:00:05 +01:00
f133bac921 CSS Fix 2024-11-27 15:00:05 +01:00
1bce7e055f Round all perms to the quarter 2024-11-27 15:00:05 +01:00
ee19dc01f6 Global code cleanup 2024-11-27 15:00:02 +01:00
09dbda87bc Activity TimeGrid WIP 2024-11-27 14:58:30 +01:00
a44e8a68cb export graph to html function 2024-11-27 14:58:27 +01:00
71d155613f functionnal api 2024-11-27 14:58:24 +01:00
e30a6e8e6e api attempt 2024-11-27 14:57:33 +01:00
10 changed files with 578 additions and 292 deletions

View File

@ -19,13 +19,16 @@ from django.db.models import Q
from ninja import Query from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from core.api_permissions import CanAccessLookup, CanView, IsRoot from core.api_permissions import CanAccessLookup, CanView, IsRoot
from counter.models import Counter, Product from counter.models import Counter, Permanency, Product
from counter.schemas import ( from counter.schemas import (
CounterFilterSchema, CounterFilterSchema,
CounterSchema, CounterSchema,
PermanencyFilterSchema,
PermanencySchema,
ProductSchema, ProductSchema,
SimplifiedCounterSchema, SimplifiedCounterSchema,
) )
@ -35,17 +38,15 @@ from counter.schemas import (
class CounterController(ControllerBase): class CounterController(ControllerBase):
@route.get("", response=list[CounterSchema], permissions=[IsRoot]) @route.get("", response=list[CounterSchema], permissions=[IsRoot])
def fetch_all(self): def fetch_all(self):
return Counter.objects.annotate_is_open() return Counter.objects.all()
@route.get("{counter_id}/", response=CounterSchema, permissions=[CanView]) @route.get("{counter_id}/", response=CounterSchema, permissions=[CanView])
def fetch_one(self, counter_id: int): def fetch_one(self, counter_id: int):
return self.get_object_or_exception( return self.get_object_or_exception(Counter.objects.all(), pk=counter_id)
Counter.objects.annotate_is_open(), pk=counter_id
)
@route.get("bar/", response=list[CounterSchema], permissions=[CanView]) @route.get("bar/", response=list[CounterSchema], permissions=[CanView])
def fetch_bars(self): def fetch_bars(self):
counters = list(Counter.objects.annotate_is_open().filter(type="BAR")) counters = list(Counter.objects.all().filter(type="BAR"))
for c in counters: for c in counters:
self.check_object_permissions(c) self.check_object_permissions(c)
return counters return counters
@ -76,3 +77,21 @@ class ProductController(ControllerBase):
.filter(archived=False) .filter(archived=False)
.values() .values()
) )
@api_controller("/permanency")
class PermanencyController(ControllerBase):
@route.get(
"",
response=PaginatedResponseSchema[PermanencySchema],
permissions=[IsAuthenticated],
exclude_none=True,
)
@paginate(PageNumberPaginationExtra, page_size=100)
def fetch_permanencies(self, filters: Query[PermanencyFilterSchema]):
return (
filters.filter(Permanency.objects.all())
.distinct()
.order_by("-start")
.select_related("counter")
)

View File

@ -1,19 +1,33 @@
from datetime import datetime
from typing import Annotated from typing import Annotated
from annotated_types import MinLen from annotated_types import MinLen
from ninja import Field, FilterSchema, ModelSchema from ninja import Field, FilterSchema, ModelSchema
from core.schemas import SimpleUserSchema from counter.models import Counter, Permanency, Product
from counter.models import Counter, Product
class CounterSchema(ModelSchema): class CounterSchema(ModelSchema):
barmen_list: list[SimpleUserSchema]
is_open: bool
class Meta: class Meta:
model = Counter model = Counter
fields = ["id", "name", "type", "club", "products"] fields = ["id", "name", "type"]
class PermanencySchema(ModelSchema):
counter: CounterSchema
class Meta:
model = Permanency
fields = ["start", "end"]
class PermanencyFilterSchema(FilterSchema):
start_after: datetime | None = Field(None, q="start__gte")
start_before: datetime | None = Field(None, q="start__lte")
end_after: datetime | None = Field(None, q="end__gte")
end_before: datetime | None = Field(None, q="end__lte")
took_place_after: datetime | None = Field(None, q=["start__gte", "end__gte"])
counter: set[int] | None = Field(None, q="counter_id__in")
class CounterFilterSchema(FilterSchema): class CounterFilterSchema(FilterSchema):

View File

@ -0,0 +1,152 @@
import { paginated } from "#core:utils/api";
import { exportToHtml } from "#core:utils/globals";
import { Calendar } from "@fullcalendar/core";
import timeGridPlugin from "@fullcalendar/timegrid";
import {
type PermanencyFetchPermanenciesData,
type PermanencySchema,
permanencyFetchPermanencies,
} from "#openapi";
interface ActivityTimeGridConfig {
canvas: HTMLCanvasElement;
startDate: Date;
counterId: number;
locale: string;
}
interface OpeningTime {
start: Date;
end: Date;
}
interface EventInput {
start: Date;
end: Date;
backgroundColor: string;
title?: string;
}
exportToHtml("loadActivityTimeGrid", loadActivityTimeGrid);
async function loadActivityTimeGrid(options: ActivityTimeGridConfig) {
const permanencies = await paginated(permanencyFetchPermanencies, {
query: {
counter: [options.counterId],
// biome-ignore lint/style/useNamingConvention: backend API uses snake_case
took_place_after: options.startDate.toISOString(),
},
} as PermanencyFetchPermanenciesData);
const events = getEvents(permanencies);
const calendar = new Calendar(options.canvas, {
plugins: [timeGridPlugin],
initialView: "timeGridWeek",
locale: options.locale,
dayHeaderFormat: { weekday: "long" },
firstDay: 1,
views: { timeGrid: { allDaySlot: false } },
scrollTime: "09:00:00",
headerToolbar: { left: "prev today", center: "title", right: "" },
events: events,
nowIndicator: true,
height: 600,
});
calendar.render();
calendar.on("datesSet", async (info) => {
if (options.startDate <= info.start) {
return;
}
const newPerms = await paginated(permanencyFetchPermanencies, {
query: {
counter: [options.counterId],
// biome-ignore lint/style/useNamingConvention: backend API uses snake_case
end_after: info.startStr,
// biome-ignore lint/style/useNamingConvention: backend API uses snake_case
start_before: info.endStr,
},
} as PermanencyFetchPermanenciesData);
options.startDate = info.start;
calendar.addEventSource(getEvents(newPerms, false));
permanencies.push(...newPerms);
calendar.render();
});
}
function roundToQuarter(date: Date, ceil: boolean) {
const result = date;
const minutes = date.getMinutes();
// removes minutes exceeding the lower quarter and adds 15 minutes if rounded to ceiling
result.setMinutes(minutes - (minutes % 15) + +ceil * 15, 0, 0);
return result;
}
function convertPermanencyToOpeningTime(permanency: PermanencySchema): OpeningTime {
return {
start: roundToQuarter(new Date(permanency.start), false),
end: roundToQuarter(new Date(permanency.end ?? Date.now()), true),
};
}
function getOpeningTimes(rawPermanencies: PermanencySchema[]) {
const permanencies = rawPermanencies
.map(convertPermanencyToOpeningTime)
.sort((a, b) => a.start.getTime() - b.start.getTime());
const openingTimes: OpeningTime[] = [];
for (const permanency of permanencies) {
// if there are no opening times, add the first one
if (openingTimes.length === 0) {
openingTimes.push(permanency);
} else {
const lastPermanency = openingTimes[openingTimes.length - 1];
// if the new permanency starts before the 15 minutes following the end of the last one, merge them
if (permanency.start <= lastPermanency.end) {
lastPermanency.end = new Date(
Math.max(lastPermanency.end.getTime(), permanency.end.getTime()),
);
} else {
openingTimes.push(permanency);
}
}
}
return openingTimes;
}
function getEvents(permanencies: PermanencySchema[], currentWeek = true): EventInput[] {
const openingTimes = getOpeningTimes(permanencies);
const events: EventInput[] = [];
for (const openingTime of openingTimes) {
let shift = false;
if (currentWeek) {
const lastMonday = getLastMonday();
shift = openingTime.end < lastMonday;
}
// if permanencies took place last week (=before monday),
// -> display them in lightblue as part of the current week
events.push({
start: shift ? shiftDateByDays(openingTime.start, 7) : openingTime.start,
end: shift ? shiftDateByDays(openingTime.end, 7) : openingTime.end,
backgroundColor: shift ? "lightblue" : "green",
});
}
return events;
}
// Function to get last Monday at 00:00
function getLastMonday(now = new Date()): Date {
const dayOfWeek = now.getDay();
const lastMonday = new Date(now);
lastMonday.setDate(now.getDate() - ((dayOfWeek + 6) % 7)); // Adjust for Monday as day 1
lastMonday.setHours(0, 0, 0, 0);
return lastMonday;
}
function shiftDateByDays(date: Date, days: number): Date {
const newDate = new Date(date);
newDate.setDate(date.getDate() + days);
return newDate;
}

View File

@ -21,4 +21,21 @@
align-items: center; align-items: center;
} }
} }
}
#activityTimeGrid {
th {
border: none;
}
.fc-col-header-cell-cushion {
color: white;
}
table table {
// the JS library puts tables inside the table
// Those additional tables must be hidden
border: none;
box-shadow: none;
}
} }

View File

@ -5,6 +5,10 @@
{% trans counter_name=counter %}{{ counter_name }} activity{% endtrans %} {% trans counter_name=counter %}{{ counter_name }} activity{% endtrans %}
{% endblock %} {% endblock %}
{% block additional_js %}
<script defer src="{{ static('bundled/counter/permanencies/time-grid-index.ts') }}" type="module"></script>
{% endblock %}
{%- block additional_css -%} {%- block additional_css -%}
<link rel="stylesheet" href="{{ static('counter/css/activity.scss') }}"> <link rel="stylesheet" href="{{ static('counter/css/activity.scss') }}">
{%- endblock -%} {%- endblock -%}
@ -22,6 +26,10 @@
{% trans %}There is currently no barman connected.{% endtrans %} {% trans %}There is currently no barman connected.{% endtrans %}
{% endif %} {% endif %}
</ul> </ul>
<h4>{% trans %}Last weeks opening times{% endtrans %}</h4>
<div id="activityTimeGrid"></div>
<br/>
<br/>
{% endif %} {% endif %}
<h5>{% trans %}Legend{% endtrans %}</h5> <h5>{% trans %}Legend{% endtrans %}</h5>
@ -37,5 +45,17 @@
</div> </div>
{% endblock %} {% endblock %}
{% block script %}
{{super()}}
<script>
window.addEventListener("DOMContentLoaded", () => {
loadActivityTimeGrid({
canvas: document.getElementById("activityTimeGrid"),
// sets the start day to 7 days ago
startDate: new Date(new Date().setDate(new Date().getDate() - 7)),
counterId: {{ counter.id }},
locale: {{ get_current_language()|tojson }}
});
});
</script>
{% endblock %}

View File

@ -6143,3 +6143,7 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
#, python-format #, python-format
msgid "Maximum characters: %(max_length)s" msgid "Maximum characters: %(max_length)s"
msgstr "Nombre de caractères max: %(max_length)s" msgstr "Nombre de caractères max: %(max_length)s"
#: counter/activity.jinja
msgid "Last weeks opening times"
msgstr "Heures d'ouverture des dernières semaines"

613
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -34,6 +34,8 @@
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.6.0", "@fortawesome/fontawesome-free": "^6.6.0",
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@hey-api/client-fetch": "^0.4.0", "@hey-api/client-fetch": "^0.4.0",
"@sentry/browser": "^8.34.0", "@sentry/browser": "^8.34.0",
"@zip.js/zip.js": "^2.7.52", "@zip.js/zip.js": "^2.7.52",

View File

@ -164,6 +164,7 @@ TEMPLATES = [
"ProductType": "counter.models.ProductType", "ProductType": "counter.models.ProductType",
"timezone": "django.utils.timezone", "timezone": "django.utils.timezone",
"get_sith": "com.views.sith", "get_sith": "com.views.sith",
"get_current_language": "django.views.i18n.get_language",
}, },
"bytecode_cache": { "bytecode_cache": {
"name": "default", "name": "default",

View File