mirror of
https://github.com/lucaspalomodevelop/eventcally.git
synced 2026-03-13 00:07:22 +00:00
Planning external calendars #561
This commit is contained in:
parent
d3375cecb1
commit
39131aded9
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@ -1,14 +1,8 @@
|
|||||||
{
|
{
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"python.pythonPath": "./env/bin/python3",
|
|
||||||
"python.formatting.provider": "black",
|
|
||||||
"isort.args": ["-sp", ".isort.cfg"],
|
"isort.args": ["-sp", ".isort.cfg"],
|
||||||
"python.linting.enabled": true,
|
|
||||||
"python.linting.pylintEnabled": false,
|
|
||||||
"python.linting.flake8Enabled": true,
|
|
||||||
"python.testing.pytestArgs": ["tests", "--capture=sys"],
|
"python.testing.pytestArgs": ["tests", "--capture=sys"],
|
||||||
"python.testing.unittestEnabled": false,
|
"python.testing.unittestEnabled": false,
|
||||||
"python.testing.nosetestsEnabled": false,
|
|
||||||
"python.testing.pytestEnabled": true,
|
"python.testing.pytestEnabled": true,
|
||||||
"[python]": {
|
"[python]": {
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
|
|||||||
33
migrations/versions/ef547963d7f0_.py
Normal file
33
migrations/versions/ef547963d7f0_.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: ef547963d7f0
|
||||||
|
Revises: 182a83a49c1f
|
||||||
|
Create Date: 2023-11-25 10:12:48.763298
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
from project import dbtypes
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "ef547963d7f0"
|
||||||
|
down_revision = "182a83a49c1f"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column(
|
||||||
|
"settings",
|
||||||
|
sa.Column("planning_external_calendars", sa.UnicodeText(), nullable=True),
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column("settings", "planning_external_calendars")
|
||||||
|
# ### end Alembic commands ###
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
from flask_babel import lazy_gettext
|
from flask_babel import lazy_gettext
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import BooleanField, RadioField, StringField, SubmitField, TextAreaField
|
from wtforms import BooleanField, RadioField, StringField, SubmitField, TextAreaField
|
||||||
@ -18,6 +20,30 @@ class AdminSettingsForm(FlaskForm):
|
|||||||
submit = SubmitField(lazy_gettext("Save"))
|
submit = SubmitField(lazy_gettext("Save"))
|
||||||
|
|
||||||
|
|
||||||
|
class AdminPlanningForm(FlaskForm):
|
||||||
|
planning_external_calendars = TextAreaField(
|
||||||
|
lazy_gettext("External calendars"), validators=[Optional()]
|
||||||
|
)
|
||||||
|
|
||||||
|
submit = SubmitField(lazy_gettext("Save"))
|
||||||
|
|
||||||
|
def validate(self, extra_validators=None):
|
||||||
|
result = super().validate(extra_validators)
|
||||||
|
|
||||||
|
if self.planning_external_calendars.data:
|
||||||
|
try:
|
||||||
|
json_object = json.loads(self.planning_external_calendars.data)
|
||||||
|
self.planning_external_calendars.data = json.dumps(
|
||||||
|
json_object, indent=2
|
||||||
|
)
|
||||||
|
except Exception as e: # pragma: no cover
|
||||||
|
msg = str(e)
|
||||||
|
self.planning_external_calendars.errors.append(msg)
|
||||||
|
result = False
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class ResetTosAceptedForm(FlaskForm):
|
class ResetTosAceptedForm(FlaskForm):
|
||||||
reset_for_users = BooleanField(
|
reset_for_users = BooleanField(
|
||||||
lazy_gettext("Reset for all users"), validators=[DataRequired()]
|
lazy_gettext("Reset for all users"), validators=[DataRequired()]
|
||||||
|
|||||||
@ -14,3 +14,4 @@ class Settings(db.Model, TrackableMixin):
|
|||||||
privacy = deferred(Column(UnicodeText()))
|
privacy = deferred(Column(UnicodeText()))
|
||||||
start_page = deferred(Column(UnicodeText()))
|
start_page = deferred(Column(UnicodeText()))
|
||||||
announcement = deferred(Column(UnicodeText()))
|
announcement = deferred(Column(UnicodeText()))
|
||||||
|
planning_external_calendars = deferred(Column(UnicodeText()))
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from sqlalchemy import and_
|
|||||||
|
|
||||||
from project.models import Event, EventOrganizer, EventPlace
|
from project.models import Event, EventOrganizer, EventPlace
|
||||||
from project.services.importer.ld_json_importer import LdJsonImporter
|
from project.services.importer.ld_json_importer import LdJsonImporter
|
||||||
|
from project.utils import decode_response_content
|
||||||
|
|
||||||
|
|
||||||
class EventImporter:
|
class EventImporter:
|
||||||
@ -21,12 +22,7 @@ class EventImporter:
|
|||||||
] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36"
|
] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36"
|
||||||
|
|
||||||
response = requests.get(sanitized_url, headers=headers)
|
response = requests.get(sanitized_url, headers=headers)
|
||||||
|
html = decode_response_content(response)
|
||||||
try:
|
|
||||||
html = response.content.decode("UTF-8")
|
|
||||||
except Exception: # pragma: no cover
|
|
||||||
html = response.content.decode(response.apparent_encoding)
|
|
||||||
|
|
||||||
return self.load_event_from_html(html, absolute_url)
|
return self.load_event_from_html(html, absolute_url)
|
||||||
|
|
||||||
def load_event_from_html(self, html: str, origin_url: str):
|
def load_event_from_html(self, html: str, origin_url: str):
|
||||||
|
|||||||
@ -39,6 +39,64 @@ const PlanningList = {
|
|||||||
</v-icon>
|
</v-icon>
|
||||||
{{ $t("comp.filter") }}
|
{{ $t("comp.filter") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
|
|
||||||
|
<v-dialog
|
||||||
|
v-model="externalMenu"
|
||||||
|
persistent
|
||||||
|
max-width="600px"
|
||||||
|
>
|
||||||
|
<template v-slot:activator="{ on, attrs }">
|
||||||
|
<v-btn color="primary"
|
||||||
|
v-if="externalCalMenuVisible"
|
||||||
|
depressed outlined class="mr-4" v-bind="attrs"
|
||||||
|
v-on="on">
|
||||||
|
<v-icon small>
|
||||||
|
mdi-calendar-multiple
|
||||||
|
</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>{{ $t("comp.externalCalTitle") }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-list two-line flat>
|
||||||
|
<v-list-item-group>
|
||||||
|
<v-list-item v-for="(externalCal, i) in externalCals" :value="externalCal" :key="i">
|
||||||
|
<template>
|
||||||
|
<v-list-item-action>
|
||||||
|
<v-checkbox v-model="externalCal.active"></v-checkbox>
|
||||||
|
</v-list-item-action>
|
||||||
|
<v-list-item-content>
|
||||||
|
<v-list-item-title v-text="externalCal.title"></v-list-item-title>
|
||||||
|
<v-list-item-subtitle v-text="externalCal.url"></v-list-item-subtitle>
|
||||||
|
</v-list-item-content>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list-item-group>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<!--<v-text-field
|
||||||
|
v-model="externalNewUrl"
|
||||||
|
@keyup.enter="addExternalUrl"
|
||||||
|
:label="$t('comp.externalCalAddUrl')"
|
||||||
|
required
|
||||||
|
></v-text-field>-->
|
||||||
|
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn
|
||||||
|
text
|
||||||
|
@click="closeExternalMenu"
|
||||||
|
>
|
||||||
|
{{ $t("shared.close") }}
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
|
|
||||||
<v-btn-toggle
|
<v-btn-toggle
|
||||||
@ -74,7 +132,7 @@ const PlanningList = {
|
|||||||
locale="de"
|
locale="de"
|
||||||
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
|
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
|
||||||
:type="type"
|
:type="type"
|
||||||
:events="events"
|
:events="allEvents"
|
||||||
:event-color="getEventColor"
|
:event-color="getEventColor"
|
||||||
event-more-text="{0} weitere"
|
event-more-text="{0} weitere"
|
||||||
@click:event="showEvent"
|
@click:event="showEvent"
|
||||||
@ -92,12 +150,19 @@ const PlanningList = {
|
|||||||
<v-card-title>{{ selectedEvent.name }}</v-card-title>
|
<v-card-title>{{ selectedEvent.name }}</v-card-title>
|
||||||
<v-card-subtitle>
|
<v-card-subtitle>
|
||||||
<v-icon small>mdi-calendar</v-icon> {{ $root.render_event_date(selectedEvent.date.start, selectedEvent.date.end, selectedEvent.date.allday) }}
|
<v-icon small>mdi-calendar</v-icon> {{ $root.render_event_date(selectedEvent.date.start, selectedEvent.date.end, selectedEvent.date.allday) }}
|
||||||
<event-warning-pills :event="selectedEvent.date.event"></event-warning-pills>
|
<event-warning-pills v-if="selectedEvent.date.event" :event="selectedEvent.date.event"></event-warning-pills>
|
||||||
</v-card-subtitle>
|
</v-card-subtitle>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
|
<template v-if="selectedEvent.date.event">
|
||||||
<div><v-icon small>mdi-database</v-icon> {{ selectedEvent.date.event.organization.name }}</div>
|
<div><v-icon small>mdi-database</v-icon> {{ selectedEvent.date.event.organization.name }}</div>
|
||||||
<div v-if="selectedEvent.date.event.organizer.name != selectedEvent.date.event.organization.name"><v-icon small>mdi-server</v-icon> {{ selectedEvent.date.event.organizer.name }}</div>
|
<div v-if="selectedEvent.date.event.organizer.name != selectedEvent.date.event.organization.name"><v-icon small>mdi-server</v-icon> {{ selectedEvent.date.event.organizer.name }}</div>
|
||||||
<div><v-icon small>mdi-map-marker</v-icon> {{ selectedEvent.date.event.place.name }}</div>
|
<div><v-icon small>mdi-map-marker</v-icon> {{ selectedEvent.date.event.place.name }}</div>
|
||||||
|
</template>
|
||||||
|
<template v-if="selectedEvent.date.vevent">
|
||||||
|
<div v-if="selectedEvent.date.vevent.description">{{ selectedEvent.date.vevent.description }}</div>
|
||||||
|
<div v-if="selectedEvent.date.vevent.location"><v-icon small>mdi-map-marker</v-icon> {{ selectedEvent.date.vevent.location }}</div>
|
||||||
|
<div><v-icon small>mdi-database</v-icon> {{ selectedEvent.date.vevent.url }}</div>
|
||||||
|
</template>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
@ -110,6 +175,7 @@ const PlanningList = {
|
|||||||
{{ $t("shared.close") }}
|
{{ $t("shared.close") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
|
v-if="selectedEvent.date.event"
|
||||||
text
|
text
|
||||||
color="primary"
|
color="primary"
|
||||||
@click="openEventDate(selectedEvent.date)"
|
@click="openEventDate(selectedEvent.date)"
|
||||||
@ -137,6 +203,8 @@ const PlanningList = {
|
|||||||
week: "Week",
|
week: "Week",
|
||||||
month: "Month",
|
month: "Month",
|
||||||
filter: "Filter",
|
filter: "Filter",
|
||||||
|
externalCalTitle: "External Calendars",
|
||||||
|
externalCalAddUrl: "Add link to iCal calendar",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
@ -148,6 +216,8 @@ const PlanningList = {
|
|||||||
week: "Woche",
|
week: "Woche",
|
||||||
month: "Monat",
|
month: "Monat",
|
||||||
filter: "Filter",
|
filter: "Filter",
|
||||||
|
externalCalTitle: "Externe Kalender",
|
||||||
|
externalCalAddUrl: "Link zu iCal-Kalendar hinzufügen",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -164,8 +234,21 @@ const PlanningList = {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
maxDates: 200,
|
maxDates: 200,
|
||||||
warning: null,
|
warning: null,
|
||||||
|
externalMenu: false,
|
||||||
|
externalNewUrl: "",
|
||||||
|
externalEvents: [],
|
||||||
|
externalCals: [],
|
||||||
}),
|
}),
|
||||||
|
computed: {
|
||||||
|
allEvents() {
|
||||||
|
return [...this.events,...this.externalEvents];
|
||||||
|
},
|
||||||
|
externalCalMenuVisible() {
|
||||||
|
return this.externalCals.length > 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
mounted () {
|
mounted () {
|
||||||
|
this.externalCals = this.$root.externalCals;
|
||||||
this.$refs.calendar.checkChange();
|
this.$refs.calendar.checkChange();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@ -177,7 +260,11 @@ const PlanningList = {
|
|||||||
calendarChanged({ start, end }) {
|
calendarChanged({ start, end }) {
|
||||||
$('#date_from').val(start.date);
|
$('#date_from').val(start.date);
|
||||||
$('#date_to').val(end.date);
|
$('#date_to').val(end.date);
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
load() {
|
||||||
this.loadEvents();
|
this.loadEvents();
|
||||||
|
this.loadExternalCalendars();
|
||||||
},
|
},
|
||||||
loadEvents(page = 1) {
|
loadEvents(page = 1) {
|
||||||
$('#page').val(page);
|
$('#page').val(page);
|
||||||
@ -220,6 +307,39 @@ const PlanningList = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
loadExternalCalendars() {
|
||||||
|
this.externalEvents = [];
|
||||||
|
|
||||||
|
this.externalCals.forEach(externalCal => {
|
||||||
|
if (externalCal.active) {
|
||||||
|
this.loadExternalCalendar(externalCal);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
loadExternalCalendar(externalCal) {
|
||||||
|
var bodyFormData = new FormData();
|
||||||
|
bodyFormData.append('date_from', $('#date_from').val());
|
||||||
|
bodyFormData.append('date_to', $('#date_to').val());
|
||||||
|
bodyFormData.append('url', externalCal.url);
|
||||||
|
|
||||||
|
const vm = this;
|
||||||
|
axios
|
||||||
|
.post(`/js/icalevents`, bodyFormData, {
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
for (const item of response.data.items) {
|
||||||
|
vm.externalEvents.push({
|
||||||
|
name: item.name,
|
||||||
|
start: moment(item.start).toDate(),
|
||||||
|
end: item.end != null ? moment(item.end).toDate() : null,
|
||||||
|
timed: !item.allday,
|
||||||
|
date: item,
|
||||||
|
color: '#aa7bff'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
viewDay ({ date }) {
|
viewDay ({ date }) {
|
||||||
this.focus = date;
|
this.focus = date;
|
||||||
this.type = 'day';
|
this.type = 'day';
|
||||||
@ -274,5 +394,17 @@ const PlanningList = {
|
|||||||
handleLoading(isLoading) {
|
handleLoading(isLoading) {
|
||||||
this.isLoading = isLoading;
|
this.isLoading = isLoading;
|
||||||
},
|
},
|
||||||
|
addExternalUrl() {
|
||||||
|
this.externalCals.push({
|
||||||
|
title: this.externalNewUrl,
|
||||||
|
url: this.externalNewUrl,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
this.externalNewUrl = "";
|
||||||
|
},
|
||||||
|
closeExternalMenu() {
|
||||||
|
this.externalMenu = false;
|
||||||
|
this.loadExternalCalendars();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -31,6 +31,10 @@
|
|||||||
{{ _('Newsletter') }}
|
{{ _('Newsletter') }}
|
||||||
<i class="fa fa-caret-right"></i>
|
<i class="fa fa-caret-right"></i>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ url_for('admin_planning') }}" class="list-group-item">
|
||||||
|
{{ _('Planning') }}
|
||||||
|
<i class="fa fa-caret-right"></i>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
38
project/templates/admin/planning.html
Normal file
38
project/templates/admin/planning.html
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% from "_macros.html" import render_field, render_field_with_errors %}
|
||||||
|
{%- block title -%}
|
||||||
|
{{ _('Planning') }}
|
||||||
|
{%- endblock -%}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('admin') }}">{{ _('Admin') }}</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{{ _('Planning') }}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<form action="" method="POST">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
<textarea class="form-control text-monospace" style="font-size: 0.7rem;" disabled rows="12">
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "Feiertage Deutschland",
|
||||||
|
"url": "https://www.feiertage-deutschland.de/kalender-download/ics/feiertage-deutschland.ics",
|
||||||
|
"active": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Schulferien Niedersachsen",
|
||||||
|
"url": "https://www.feiertage-deutschland.de/kalender-download/ics/schulferien-niedersachsen.ics",
|
||||||
|
"active": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
</textarea>
|
||||||
|
|
||||||
|
{{ render_field_with_errors(form.planning_external_calendars) }}
|
||||||
|
|
||||||
|
{{ render_field(form.submit) }}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@ -108,6 +108,7 @@
|
|||||||
},
|
},
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
close: "Close",
|
close: "Close",
|
||||||
|
refresh: "Refresh",
|
||||||
decline: "Decline",
|
decline: "Decline",
|
||||||
docs: "Docs",
|
docs: "Docs",
|
||||||
save: "Save",
|
save: "Save",
|
||||||
@ -230,6 +231,7 @@
|
|||||||
close: "Schließen",
|
close: "Schließen",
|
||||||
decline: "Ablehnen",
|
decline: "Ablehnen",
|
||||||
docs: "Doku",
|
docs: "Doku",
|
||||||
|
refresh: "Aktualisieren",
|
||||||
save: "Speichern",
|
save: "Speichern",
|
||||||
submit: "Senden",
|
submit: "Senden",
|
||||||
view: "Anzeigen",
|
view: "Anzeigen",
|
||||||
@ -382,6 +384,9 @@
|
|||||||
var vue_app_data = {};
|
var vue_app_data = {};
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block vue_app_data_fill %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% if config["DOCS_URL"] %}
|
{% if config["DOCS_URL"] %}
|
||||||
vue_app_data["docsUrl"] = "{{ config["DOCS_URL"] }}";
|
vue_app_data["docsUrl"] = "{{ config["DOCS_URL"] }}";
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -43,6 +43,10 @@ Vue.component("PlanningList", PlanningList);
|
|||||||
vue_init_data.vuetify = new Vuetify();
|
vue_init_data.vuetify = new Vuetify();
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block vue_app_data_fill %}
|
||||||
|
vue_app_data["externalCals"] = {{ initial_external_calendars | safe }};
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block vue_container %}
|
{% block vue_container %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
|
import requests
|
||||||
from flask_babel import lazy_gettext
|
from flask_babel import lazy_gettext
|
||||||
from psycopg2.errorcodes import CHECK_VIOLATION, UNIQUE_VIOLATION
|
from psycopg2.errorcodes import CHECK_VIOLATION, UNIQUE_VIOLATION
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
@ -123,3 +124,10 @@ def get_pending_changes(
|
|||||||
result[attr.key] = [new_value, old_value]
|
result[attr.key] = [new_value, old_value]
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def decode_response_content(response: requests.Response) -> str:
|
||||||
|
try:
|
||||||
|
return response.content.decode("UTF-8")
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
return response.content.decode(response.apparent_encoding)
|
||||||
|
|||||||
@ -7,6 +7,7 @@ from sqlalchemy.sql import func
|
|||||||
from project import app, db
|
from project import app, db
|
||||||
from project.forms.admin import (
|
from project.forms.admin import (
|
||||||
AdminNewsletterForm,
|
AdminNewsletterForm,
|
||||||
|
AdminPlanningForm,
|
||||||
AdminSettingsForm,
|
AdminSettingsForm,
|
||||||
AdminTestEmailForm,
|
AdminTestEmailForm,
|
||||||
DeleteAdminUnitForm,
|
DeleteAdminUnitForm,
|
||||||
@ -263,3 +264,25 @@ def admin_user_delete(id):
|
|||||||
flash_errors(form)
|
flash_errors(form)
|
||||||
|
|
||||||
return render_template("admin/delete_user.html", form=form, user=user)
|
return render_template("admin/delete_user.html", form=form, user=user)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/admin/planning", methods=("GET", "POST"))
|
||||||
|
@roles_required("admin")
|
||||||
|
def admin_planning():
|
||||||
|
settings = upsert_settings()
|
||||||
|
form = AdminPlanningForm(obj=settings)
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
form.populate_obj(settings)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
flash(gettext("Settings successfully updated"), "success")
|
||||||
|
return redirect(url_for("admin"))
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(handleSqlError(e), "danger")
|
||||||
|
else:
|
||||||
|
flash_errors(form)
|
||||||
|
|
||||||
|
return render_template("admin/planning.html", form=form)
|
||||||
|
|||||||
@ -1,18 +1,24 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import icalendar
|
||||||
|
import recurring_ical_events
|
||||||
|
import requests
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask.json import jsonify
|
from flask.json import jsonify
|
||||||
from flask_babel import gettext
|
from flask_babel import gettext
|
||||||
from flask_cors import cross_origin
|
from flask_cors import cross_origin
|
||||||
from flask_security import url_for_security
|
from flask_security import auth_required, url_for_security
|
||||||
from flask_security.utils import localize_callback
|
from flask_security.utils import localize_callback
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
from project import app, csrf
|
from project import app, csrf
|
||||||
from project.api.custom_widget.schemas import CustomWidgetSchema
|
from project.api.custom_widget.schemas import CustomWidgetSchema
|
||||||
|
from project.dateutils import form_input_to_date
|
||||||
from project.maputils import find_gmaps_places, get_gmaps_place
|
from project.maputils import find_gmaps_places, get_gmaps_place
|
||||||
from project.models import AdminUnit, CustomWidget, EventOrganizer, EventPlace
|
from project.models import AdminUnit, CustomWidget, EventOrganizer, EventPlace
|
||||||
from project.services.place import get_event_places
|
from project.services.place import get_event_places
|
||||||
from project.services.user import find_user_by_email
|
from project.services.user import find_user_by_email
|
||||||
from project.utils import get_place_str
|
from project.utils import decode_response_content, get_place_str
|
||||||
|
|
||||||
|
|
||||||
@app.route("/js/check/organization/short_name", methods=["POST"])
|
@app.route("/js/check/organization/short_name", methods=["POST"])
|
||||||
@ -181,6 +187,70 @@ def js_autocomplete_gmaps_place():
|
|||||||
return jsonify(place)
|
return jsonify(place)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/js/icalevents", methods=["POST"])
|
||||||
|
@auth_required()
|
||||||
|
def js_icalevents():
|
||||||
|
csrf.protect()
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = request.form["url"]
|
||||||
|
date_from = request.form["date_from"]
|
||||||
|
date_to = request.form["date_to"]
|
||||||
|
|
||||||
|
start_date = form_input_to_date(date_from).date()
|
||||||
|
end_date = form_input_to_date(date_to).date()
|
||||||
|
|
||||||
|
response = requests.get(url)
|
||||||
|
ical_string = decode_response_content(response)
|
||||||
|
calendar = icalendar.Calendar.from_ical(ical_string)
|
||||||
|
events = recurring_ical_events.of(calendar).between(start_date, end_date)
|
||||||
|
items = list()
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
summary = event.get("SUMMARY")
|
||||||
|
dt_start = event.get("DTSTART")
|
||||||
|
dt_end = event.get("DTEND")
|
||||||
|
location = event.get("LOCATION")
|
||||||
|
description = event.get("DESCRIPTION")
|
||||||
|
|
||||||
|
if not summary or not dt_start: # pragma: no cover
|
||||||
|
continue
|
||||||
|
|
||||||
|
start = dt_start.dt
|
||||||
|
|
||||||
|
item = {
|
||||||
|
"name": summary,
|
||||||
|
"start": start,
|
||||||
|
"allday": not isinstance(start, datetime),
|
||||||
|
}
|
||||||
|
|
||||||
|
if dt_end:
|
||||||
|
item["end"] = dt_end.dt
|
||||||
|
|
||||||
|
vevent = {
|
||||||
|
"url": url,
|
||||||
|
}
|
||||||
|
|
||||||
|
if location:
|
||||||
|
vevent["location"] = location
|
||||||
|
|
||||||
|
if dt_end:
|
||||||
|
vevent["description"] = description
|
||||||
|
|
||||||
|
item["vevent"] = vevent
|
||||||
|
items.append(item)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"url": url,
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e: # pragma: no cover
|
||||||
|
app.logger.exception(url)
|
||||||
|
return getattr(e, "message", "Unknown error"), 400
|
||||||
|
|
||||||
|
|
||||||
@app.route("/js/wlcw/<int:id>")
|
@app.route("/js/wlcw/<int:id>")
|
||||||
@cross_origin()
|
@cross_origin()
|
||||||
def js_widget_loader_custom_widget(id: int):
|
def js_widget_loader_custom_widget(id: int):
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from flask_security import auth_required
|
|||||||
from project import app
|
from project import app
|
||||||
from project.access import can_use_planning
|
from project.access import can_use_planning
|
||||||
from project.forms.planning import PlanningForm
|
from project.forms.planning import PlanningForm
|
||||||
|
from project.services.admin import upsert_settings
|
||||||
from project.services.search_params import EventSearchParams
|
from project.services.search_params import EventSearchParams
|
||||||
from project.views.event import get_event_category_choices
|
from project.views.event import get_event_category_choices
|
||||||
from project.views.utils import permission_missing
|
from project.views.utils import permission_missing
|
||||||
@ -24,4 +25,16 @@ def planning():
|
|||||||
form.weekday.data = [c[0] for c in form.weekday.choices]
|
form.weekday.data = [c[0] for c in form.weekday.choices]
|
||||||
form.exclude_recurring.data = True
|
form.exclude_recurring.data = True
|
||||||
|
|
||||||
return render_template("planning/list.html", form=form, params=params)
|
settings = upsert_settings()
|
||||||
|
initial_external_calendars = (
|
||||||
|
settings.planning_external_calendars
|
||||||
|
if settings.planning_external_calendars
|
||||||
|
else "[]"
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"planning/list.html",
|
||||||
|
form=form,
|
||||||
|
params=params,
|
||||||
|
initial_external_calendars=initial_external_calendars,
|
||||||
|
)
|
||||||
|
|||||||
@ -60,7 +60,7 @@ GeoAlchemy2==0.13.1
|
|||||||
googlemaps==4.10.0
|
googlemaps==4.10.0
|
||||||
greenlet==2.0.2
|
greenlet==2.0.2
|
||||||
gunicorn==20.1.0
|
gunicorn==20.1.0
|
||||||
icalendar==5.0.5
|
icalendar==5.0.11
|
||||||
identify==1.5.10
|
identify==1.5.10
|
||||||
idna==2.10
|
idna==2.10
|
||||||
importlib-metadata==6.3.0
|
importlib-metadata==6.3.0
|
||||||
@ -109,9 +109,10 @@ python-dateutil==2.8.1
|
|||||||
python-dotenv==0.15.0
|
python-dotenv==0.15.0
|
||||||
python-editor==1.0.4
|
python-editor==1.0.4
|
||||||
pytoolconfig==1.2.5
|
pytoolconfig==1.2.5
|
||||||
pytz==2022.7.1
|
pytz==2023.3
|
||||||
PyYAML==6.0.1
|
PyYAML==6.0.1
|
||||||
qrcode==6.1
|
qrcode==6.1
|
||||||
|
recurring-ical-events==2.1.0
|
||||||
redis==4.5.4
|
redis==4.5.4
|
||||||
regex==2023.3.23
|
regex==2023.3.23
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
|
|||||||
2464
tests/views/data/feiertage-deutschland.ics
Normal file
2464
tests/views/data/feiertage-deutschland.ics
Normal file
File diff suppressed because it is too large
Load Diff
105
tests/views/data/recurring-events-changed-duration.ics
Normal file
105
tests/views/data/recurring-events-changed-duration.ics
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
|
||||||
|
VERSION:2.0
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:Europe/Berlin
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
TZOFFSETFROM:+0100
|
||||||
|
TZOFFSETTO:+0200
|
||||||
|
TZNAME:CEST
|
||||||
|
DTSTART:19700329T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:STANDARD
|
||||||
|
TZOFFSETFROM:+0200
|
||||||
|
TZOFFSETTO:+0100
|
||||||
|
TZNAME:CET
|
||||||
|
DTSTART:19701025T030000
|
||||||
|
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
|
||||||
|
END:STANDARD
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
CREATED:20190303T154052Z
|
||||||
|
LAST-MODIFIED:20190303T154956Z
|
||||||
|
DTSTAMP:20190303T154956Z
|
||||||
|
UID:5d4c6843-9300-4f91-8d88-6094d4b0b840
|
||||||
|
SUMMARY:test7
|
||||||
|
RRULE:FREQ=DAILY;UNTIL=20190320T030000Z
|
||||||
|
DTSTART;TZID=Europe/Berlin:20190318T040000
|
||||||
|
DTEND;TZID=Europe/Berlin:20190318T050000
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
X-MOZ-GENERATION:4
|
||||||
|
SEQUENCE:1
|
||||||
|
DESCRIPTION:description should be the same
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
CREATED:20190303T154131Z
|
||||||
|
LAST-MODIFIED:20190303T154145Z
|
||||||
|
DTSTAMP:20190303T154145Z
|
||||||
|
UID:5d4c6843-9300-4f91-8d88-6094d4b0b840
|
||||||
|
SUMMARY:test7 - edited
|
||||||
|
RECURRENCE-ID;TZID=Europe/Berlin:20190319T040000
|
||||||
|
DTSTART;TZID=Europe/Berlin:20190319T040000
|
||||||
|
DTEND;TZID=Europe/Berlin:20190319T050000
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
X-MOZ-GENERATION:3
|
||||||
|
SEQUENCE:2
|
||||||
|
LOCATION:location
|
||||||
|
X-LIC-ERROR:No value for CLASS property. Removing entire property:
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
CREATED:20190307T194152Z
|
||||||
|
LAST-MODIFIED:20190307T195005Z
|
||||||
|
DTSTAMP:20190307T195005Z
|
||||||
|
UID:a0c78729-30b1-4ba3-a86e-6aedd995d788
|
||||||
|
SUMMARY:New Event
|
||||||
|
RRULE:FREQ=DAILY;UNTIL=20190310T010000Z
|
||||||
|
DTSTART;TZID=Europe/Berlin:20190307T020000
|
||||||
|
DTEND;TZID=Europe/Berlin:20190307T030000
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
SEQUENCE:1
|
||||||
|
X-MOZ-GENERATION:6
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
CREATED:20190307T194207Z
|
||||||
|
LAST-MODIFIED:20190307T194945Z
|
||||||
|
DTSTAMP:20190307T194945Z
|
||||||
|
UID:a0c78729-30b1-4ba3-a86e-6aedd995d788
|
||||||
|
SUMMARY:New Event
|
||||||
|
RECURRENCE-ID;TZID=Europe/Berlin:20190308T020000
|
||||||
|
DTSTART;TZID=Europe/Berlin:20190308T010000
|
||||||
|
DTEND;TZID=Europe/Berlin:20190308T030000
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
SEQUENCE:3
|
||||||
|
X-MOZ-GENERATION:2
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
CREATED:20190307T194214Z
|
||||||
|
LAST-MODIFIED:20190307T194952Z
|
||||||
|
DTSTAMP:20190307T194952Z
|
||||||
|
UID:a0c78729-30b1-4ba3-a86e-6aedd995d788
|
||||||
|
SUMMARY:New Event
|
||||||
|
RECURRENCE-ID;TZID=Europe/Berlin:20190309T020000
|
||||||
|
DTSTART;TZID=Europe/Berlin:20190309T030000
|
||||||
|
DTEND;TZID=Europe/Berlin:20190309T033000
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
SEQUENCE:3
|
||||||
|
X-MOZ-GENERATION:3
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
CREATED:20190307T194955Z
|
||||||
|
LAST-MODIFIED:20190307T195005Z
|
||||||
|
DTSTAMP:20190307T195005Z
|
||||||
|
UID:a0c78729-30b1-4ba3-a86e-6aedd995d788
|
||||||
|
SUMMARY:New Event
|
||||||
|
RECURRENCE-ID;TZID=Europe/Berlin:20190310T020000
|
||||||
|
DTSTART;VALUE=DATE:20190310
|
||||||
|
DTEND;VALUE=DATE:20190311
|
||||||
|
TRANSP:TRANSPARENT
|
||||||
|
SEQUENCE:2
|
||||||
|
X-MOZ-GENERATION:6
|
||||||
|
X-LIC-ERROR;X-LIC-ERRORTYPE=VALUE-PARSE-ERROR:No value for CLASS property.
|
||||||
|
Removing entire property:
|
||||||
|
DURATION:PT0S
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
@ -309,3 +309,34 @@ def test_admin_reset_tos_accepted(client, app, db, seeder: Seeder, utils: UtilAc
|
|||||||
from project.models.user import User
|
from project.models.user import User
|
||||||
|
|
||||||
assert len(User.query.filter(User.tos_accepted_at.isnot(None)).all()) == 0
|
assert len(User.query.filter(User.tos_accepted_at.isnot(None)).all()) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("db_error", [True, False])
|
||||||
|
def test_admin_planning(client, seeder, utils, app, mocker, db_error):
|
||||||
|
user_id, admin_unit_id = seeder.setup_base(True)
|
||||||
|
|
||||||
|
url = utils.get_url("admin_planning")
|
||||||
|
response = utils.get_ok(url)
|
||||||
|
|
||||||
|
if db_error:
|
||||||
|
utils.mock_db_commit(mocker)
|
||||||
|
|
||||||
|
response = utils.post_form(
|
||||||
|
url,
|
||||||
|
response,
|
||||||
|
{
|
||||||
|
"planning_external_calendars": "[]",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if db_error:
|
||||||
|
utils.assert_response_db_error(response)
|
||||||
|
return
|
||||||
|
|
||||||
|
utils.assert_response_redirect(response, "admin")
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
from project.services.admin import upsert_settings
|
||||||
|
|
||||||
|
settings = upsert_settings()
|
||||||
|
assert settings.planning_external_calendars == "[]"
|
||||||
|
|||||||
@ -298,3 +298,36 @@ def test_js_widget_loader_custom_widget(client, seeder: Seeder, utils: UtilActio
|
|||||||
|
|
||||||
url = utils.get_url("js_widget_loader_custom_widget", id=custom_widget_id)
|
url = utils.get_url("js_widget_loader_custom_widget", id=custom_widget_id)
|
||||||
utils.get_ok(url)
|
utils.get_ok(url)
|
||||||
|
|
||||||
|
|
||||||
|
def test_js_icalevents(
|
||||||
|
client, seeder: Seeder, utils: UtilActions, shared_datadir, requests_mock
|
||||||
|
):
|
||||||
|
user_id, admin_unit_id = seeder.setup_base()
|
||||||
|
url = utils.get_url("planning")
|
||||||
|
utils.get(url)
|
||||||
|
|
||||||
|
params = (client, utils, shared_datadir)
|
||||||
|
_assert_icalevents(params, "feiertage-deutschland.ics")
|
||||||
|
_assert_icalevents(params, "recurring-events-changed-duration.ics")
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_icalevents(params, filename):
|
||||||
|
client, utils, datadir = params
|
||||||
|
|
||||||
|
utils.mock_get_request_with_file("http://test.de", datadir, filename)
|
||||||
|
|
||||||
|
with client:
|
||||||
|
url = utils.get_url("js_icalevents")
|
||||||
|
response = utils.post_form_data(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
"date_from": "2019-03-05",
|
||||||
|
"date_to": "2019-04-01",
|
||||||
|
"url": "http://test.de",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
utils.assert_response_ok(response)
|
||||||
|
json = response.json
|
||||||
|
first = json["items"][0]
|
||||||
|
assert first["name"]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user