diff --git a/README.md b/README.md index c98d408..98ae204 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Pour ajouter une nouvelle source custom: ### Récupérer un dump du prod sur un serveur dev * sur le serveur de dev: - * ```docker exec -i agenda_culturel-backend python3 manage.py dumpdata --format=json --exclude=admin.logentry --exclude=auth.group --exclude=auth.permission --exclude=auth.user --exclude=contenttypes --indent=2 > fixtures/postgres-backup-20241101.json``` (à noter qu'ici on oublie les comptes, qu'il faudra recréer) + * ```docker exec -i agenda_culturel-backend python3 manage.py dumpdata --natural-foreign --natural-primary --format=json --exclude=admin.logentry --indent=2 > fixtures/postgres-backup-20241101.json``` (à noter qu'ici on oublie les comptes, qu'il faudra recréer) * sur le serveur de prod: * On récupère le dump json ```scp $SERVEUR:$PATH/fixtures/postgres-backup-20241101.json src/fixtures/``` * ```scripts/reset-database.sh FIXTURE COMMIT``` où ```FIXTURE``` est le timestamp dans le nom de la fixture, et ```COMMIT``` est l'ID du commit git correspondant à celle en prod sur le serveur au moment de la création de la fixture diff --git a/deployment/Dockerfile b/deployment/Dockerfile index 533a9e8..7f038bf 100644 --- a/deployment/Dockerfile +++ b/deployment/Dockerfile @@ -5,10 +5,11 @@ WORKDIR /usr/src/app RUN --mount=type=cache,target=/var/cache/apt \ apt-get update && \ - apt-get install --no-install-recommends -y build-essential libpq-dev gettext chromium-driver gdal-bin \ + apt-get install --no-install-recommends -y build-essential libpq-dev gettext chromium-driver gdal-bin fonts-symbola \ && rm -rf /var/lib/apt/lists/* + COPY src/requirements.txt ./requirements.txt RUN --mount=type=cache,target=/root/.cache/pip \ diff --git a/experimentations/get_le_rio.py b/experimentations/get_le_rio.py new file mode 100755 index 0000000..35e0364 --- /dev/null +++ b/experimentations/get_le_rio.py @@ -0,0 +1,43 @@ +#!/usr/bin/python3 +# coding: utf-8 + +import os +import json +import sys + +# getting the name of the directory +# where the this file is present. +current = os.path.dirname(os.path.realpath(__file__)) + +# Getting the parent directory name +# where the current directory is present. +parent = os.path.dirname(current) + +# adding the parent directory to +# the sys.path. +sys.path.append(parent) + +from src.agenda_culturel.import_tasks.downloader import * +from src.agenda_culturel.import_tasks.extractor import * +from src.agenda_culturel.import_tasks.importer import * +from src.agenda_culturel.import_tasks.custom_extractors import * + + + + + +if __name__ == "__main__": + + u2e = URL2Events(SimpleDownloader(), lerio.CExtractor()) + url = "https://www.cinemalerio.com/evenements/" + url_human = "https://www.cinemalerio.com/evenements/" + + try: + events = u2e.process(url, url_human, cache = "cache-le-rio.html", default_values = {"location": "Cinéma le Rio", "category": "Cinéma"}, published = True) + + exportfile = "events-le-roi.json" + print("Saving events to file {}".format(exportfile)) + with open(exportfile, "w") as f: + json.dump(events, f, indent=4, default=str) + except Exception as e: + print("Exception: " + str(e)) diff --git a/scripts/reset-database.sh b/scripts/reset-database.sh index a33b74c..23afac3 100755 --- a/scripts/reset-database.sh +++ b/scripts/reset-database.sh @@ -73,6 +73,10 @@ git checkout $COMMIT echobold "Setup database stucture according to the selected commit" docker exec -i agenda_culturel-backend python3 manage.py migrate agenda_culturel +# remove all elements in database +echobold "Flush database" +docker exec -i agenda_culturel-backend python3 manage.py flush --no-input + # import data echobold "Import data" docker exec -i agenda_culturel-backend python3 manage.py loaddata --format=json $FFILE @@ -85,7 +89,4 @@ git checkout main echobold "Update database" docker exec -i agenda_culturel-backend python3 manage.py migrate agenda_culturel -# create superuser -echobold "Create superuser" -docker exec -ti agenda_culturel-backend python3 manage.py createsuperuser diff --git a/src/agenda_culturel/admin.py b/src/agenda_culturel/admin.py index 9de4a23..8c5d32a 100644 --- a/src/agenda_culturel/admin.py +++ b/src/agenda_culturel/admin.py @@ -9,7 +9,7 @@ from .models import ( BatchImportation, RecurrentImport, Place, - ContactMessage, + Message, ReferenceLocation, Organisation ) @@ -25,7 +25,7 @@ admin.site.register(DuplicatedEvents) admin.site.register(BatchImportation) admin.site.register(RecurrentImport) admin.site.register(Place) -admin.site.register(ContactMessage) +admin.site.register(Message) admin.site.register(ReferenceLocation) admin.site.register(Organisation) diff --git a/src/agenda_culturel/calendar.py b/src/agenda_culturel/calendar.py index a62e9ac..aa110f6 100644 --- a/src/agenda_culturel/calendar.py +++ b/src/agenda_culturel/calendar.py @@ -117,6 +117,23 @@ class DayInCalendar: if e.start_time is None else e.start_time ) + self.today_night = False + if self.is_today(): + self.today_night = True + now = timezone.now() + nday = now.date() + ntime = now.time() + found = False + for idx,e in enumerate(self.events): + if (nday < e.start_day) or (nday == e.start_day and e.start_time and ntime <= e.start_time): + self.events[idx].is_first_after_now = True + found = True + break + if found: + self.today_night = False + + def is_today_after_events(self): + return self.is_today() and self.today_night def events_by_category_ordered(self): from .models import Category @@ -175,12 +192,13 @@ class IntervalInDay(DayInCalendar): self.id = self.id + '-' + str(id) class CalendarList: - def __init__(self, firstdate, lastdate, filter=None, exact=False, ignore_dup=None): + def __init__(self, firstdate, lastdate, filter=None, exact=False, ignore_dup=None, qs=None): self.firstdate = firstdate self.lastdate = lastdate self.now = date.today() self.filter = filter self.ignore_dup = ignore_dup + self.qs = qs if exact: self.c_firstdate = self.firstdate @@ -219,9 +237,12 @@ class CalendarList: def fill_calendar_days(self): if self.filter is None: - from .models import Event + if self.qs is None: + from .models import Event - qs = Event.objects.all() + qs = Event.objects.all() + else: + qs = self.qs else: qs = self.filter.qs @@ -229,7 +250,7 @@ class CalendarList: qs = qs.exclude(other_versions=self.ignore_dup) startdatetime = timezone.make_aware(datetime.combine(self.c_firstdate, time.min), timezone.get_default_timezone()) lastdatetime = timezone.make_aware(datetime.combine(self.c_lastdate, time.max), timezone.get_default_timezone()) - self.events = qs.filter( + qs = qs.filter( (Q(recurrence_dtend__isnull=True) & Q(recurrence_dtstart__lte=lastdatetime)) | ( Q(recurrence_dtend__isnull=False) @@ -243,7 +264,10 @@ class CalendarList: Q(other_versions__isnull=True) | Q(other_versions__representative=F('pk')) | Q(other_versions__representative__isnull=True) - ).order_by("start_time", "title__unaccent__lower").select_related("exact_location").select_related("category").select_related("other_versions").select_related("other_versions__representative") + ).order_by("start_time", "title__unaccent__lower") + + qs = qs.select_related("exact_location").select_related("category").select_related("other_versions").select_related("other_versions__representative") + self.events = qs firstdate = datetime.fromordinal(self.c_firstdate.toordinal()) if firstdate.tzinfo is None or firstdate.tzinfo.utcoffset(firstdate) is None: @@ -291,14 +315,14 @@ class CalendarList: def time_intervals_list_first(self): return self.time_intervals_list(True) - def export_to_ics(self): + def export_to_ics(self, request): from .models import Event events = [event for day in self.get_calendar_days().values() for event in day.events] - return Event.export_to_ics(events) + return Event.export_to_ics(events, request) class CalendarMonth(CalendarList): - def __init__(self, year, month, filter): + def __init__(self, year, month, filter, qs=None): self.year = year self.month = month r = calendar.monthrange(year, month) @@ -306,7 +330,7 @@ class CalendarMonth(CalendarList): first = date(year, month, 1) last = date(year, month, r[1]) - super().__init__(first, last, filter) + super().__init__(first, last, filter, qs) def get_month_name(self): return self.firstdate.strftime("%B") @@ -319,14 +343,14 @@ class CalendarMonth(CalendarList): class CalendarWeek(CalendarList): - def __init__(self, year, week, filter): + def __init__(self, year, week, filter, qs=None): self.year = year self.week = week first = date.fromisocalendar(self.year, self.week, 1) last = date.fromisocalendar(self.year, self.week, 7) - super().__init__(first, last, filter) + super().__init__(first, last, filter, qs) def next_week(self): return self.firstdate + timedelta(days=7) @@ -336,8 +360,8 @@ class CalendarWeek(CalendarList): class CalendarDay(CalendarList): - def __init__(self, date, filter=None): - super().__init__(date, date, filter, exact=True) + def __init__(self, date, filter=None, qs=None): + super().__init__(date, date, filter=filter, qs=qs, exact=True) def get_events(self): return self.calendar_days_list()[0].events diff --git a/src/agenda_culturel/celery.py b/src/agenda_culturel/celery.py index b435900..a8471a8 100644 --- a/src/agenda_culturel/celery.py +++ b/src/agenda_culturel/celery.py @@ -6,7 +6,8 @@ from celery.schedules import crontab from celery.utils.log import get_task_logger from celery.exceptions import MaxRetriesExceededError import time as time_ - +from django.conf import settings +from celery.signals import worker_ready from contextlib import contextmanager @@ -147,6 +148,8 @@ def run_recurrent_import_internal(rimport, downloader, req_id): extractor = c3c.CExtractor() elif rimport.processor == RecurrentImport.PROCESSOR.ARACHNEE: extractor = arachnee.CExtractor() + elif rimport.processor == RecurrentImport.PROCESSOR.LERIO: + extractor = lerio.CExtractor() else: extractor = None @@ -173,6 +176,11 @@ def run_recurrent_import_internal(rimport, downloader, req_id): published=published, ) + # force location if required + if rimport.forceLocation and location: + for i, e in enumerate(events['events']): + events['events'][i]["location"] = location + # convert it to json json_events = json.dumps(events, default=str) @@ -248,6 +256,23 @@ def daily_imports(self): run_recurrent_imports_from_list([imp.pk for imp in imports]) +SCREENSHOT_FILE = settings.MEDIA_ROOT + '/screenshot.png' + +@app.task(bind=True) +def screenshot(self): + downloader = ChromiumHeadlessDownloader(noimage=False) + downloader.screenshot("https://pommesdelune.fr", SCREENSHOT_FILE) + +@worker_ready.connect +def at_start(sender, **k): + if not os.path.isfile(SCREENSHOT_FILE): + logger.info("Init screenshot file") + with sender.app.connection() as conn: + sender.app.send_task('agenda_culturel.celery.screenshot', None, connection=conn) + else: + logger.info("Screenshot file already exists") + + @app.task(bind=True) def run_all_recurrent_imports(self): from agenda_culturel.models import RecurrentImport @@ -289,7 +314,7 @@ def weekly_imports(self): run_recurrent_imports_from_list([imp.pk for imp in imports]) @app.task(base=ChromiumTask, bind=True) -def import_events_from_url(self, url, cat, tags, force=False): +def import_events_from_url(self, url, cat, tags, force=False, user_id=None): from .db_importer import DBImporterEvents from agenda_culturel.models import RecurrentImport, BatchImportation from agenda_culturel.models import Event, Category @@ -298,7 +323,7 @@ def import_events_from_url(self, url, cat, tags, force=False): if acquired: - logger.info("URL import: {}".format(self.request.id)) + logger.info("URL import: {}".format(self.request.id) + " force " + str(force)) # clean url @@ -335,7 +360,7 @@ def import_events_from_url(self, url, cat, tags, force=False): json_events = json.dumps(events, default=str) # import events (from json) - success, error_message = importer.import_events(json_events) + success, error_message = importer.import_events(json_events, user_id) # finally, close task close_import_task(self.request.id, success, error_message, importer) @@ -352,14 +377,14 @@ def import_events_from_url(self, url, cat, tags, force=False): @app.task(base=ChromiumTask, bind=True) -def import_events_from_urls(self, urls_cat_tags): +def import_events_from_urls(self, urls_cat_tags, user_id=None): for ucat in urls_cat_tags: if ucat is not None: url = ucat[0] cat = ucat[1] tags = ucat[2] - import_events_from_url.delay(url, cat, tags) + import_events_from_url.delay(url, cat, tags, user_id=user_id) app.conf.beat_schedule = { @@ -368,6 +393,10 @@ app.conf.beat_schedule = { # Daily imports at 3:14 a.m. "schedule": crontab(hour=3, minute=14), }, + "daily_screenshot": { + "task": "agenda_culturel.celery.screenshot", + "schedule": crontab(hour=3, minute=3), + }, "weekly_imports": { "task": "agenda_culturel.celery.weekly_imports", # Daily imports on Mondays at 2:22 a.m. diff --git a/src/agenda_culturel/db_importer.py b/src/agenda_culturel/db_importer.py index de38ce8..24e9c22 100644 --- a/src/agenda_culturel/db_importer.py +++ b/src/agenda_culturel/db_importer.py @@ -11,6 +11,7 @@ class DBImporterEvents: def __init__(self, celery_id): self.celery_id = celery_id self.error_message = "" + self.user_id = None self.init_result_properties() self.today = timezone.now().date().isoformat() @@ -34,9 +35,10 @@ class DBImporterEvents: def get_nb_removed_events(self): return self.nb_removed - def import_events(self, json_structure): + def import_events(self, json_structure, user_id=None): print(json_structure) self.init_result_properties() + self.user_id = user_id try: structure = json.loads(json_structure) @@ -95,7 +97,7 @@ class DBImporterEvents: def save_imported(self): self.db_event_objects, self.nb_updated, self.nb_removed = Event.import_events( - self.event_objects, remove_missing_from_source=self.url + self.event_objects, remove_missing_from_source=self.url, user_id=self.user_id ) def is_valid_event_structure(self, event): diff --git a/src/agenda_culturel/filters.py b/src/agenda_culturel/filters.py index 07c23e8..bb88335 100644 --- a/src/agenda_culturel/filters.py +++ b/src/agenda_culturel/filters.py @@ -44,7 +44,7 @@ from .models import ( Tag, Event, Category, - ContactMessage, + Message, DuplicatedEvents ) @@ -137,7 +137,11 @@ class EventFilter(django_filters.FilterSet): if self.get_cleaned_data("position") is None or self.get_cleaned_data("radius") is None: return parent d = self.get_cleaned_data("radius") - p = self.get_cleaned_data("position").location + p = self.get_cleaned_data("position") + if not isinstance(d, str) or not isinstance(p, ReferenceLocation): + return parent + p = p.location + return parent.exclude(exact_location=False).filter(exact_location__location__distance_lt=(p, D(km=d))) def get_url(self): @@ -188,6 +192,7 @@ class EventFilter(django_filters.FilterSet): def get_cleaned_data(self, name): + try: return self.form.cleaned_data[name] except AttributeError: @@ -356,7 +361,7 @@ class EventFilterAdmin(django_filters.FilterSet): fields = ["status"] -class ContactMessagesFilterAdmin(django_filters.FilterSet): +class MessagesFilterAdmin(django_filters.FilterSet): closed = django_filters.MultipleChoiceFilter( label="Status", choices=((True, _("Closed")), (False, _("Open"))), @@ -369,7 +374,7 @@ class ContactMessagesFilterAdmin(django_filters.FilterSet): ) class Meta: - model = ContactMessage + model = Message fields = ["closed", "spam"] diff --git a/src/agenda_culturel/forms.py b/src/agenda_culturel/forms.py index f93b4db..d5cf26b 100644 --- a/src/agenda_culturel/forms.py +++ b/src/agenda_culturel/forms.py @@ -16,6 +16,7 @@ from django.forms import ( ) from django_better_admin_arrayfield.forms.widgets import DynamicArrayWidget +from .utils import PlaceGuesser from .models import ( Event, RecurrentImport, @@ -23,7 +24,7 @@ from .models import ( Place, Category, Tag, - ContactMessage + Message ) from django.conf import settings from django.core.files import File @@ -32,7 +33,6 @@ from django.utils.translation import gettext_lazy as _ from string import ascii_uppercase as auc from .templatetags.utils_extra import int_to_abc from django.utils.safestring import mark_safe -from django.utils.timezone import localtime from django.utils.formats import localize from .templatetags.event_extra import event_field_verbose_name, field_to_html import os @@ -74,7 +74,7 @@ class GroupFormMixin: return [f for f in self.visible_fields() if not hasattr(f.field, "toggle_group") and (not hasattr(f.field, "group_id") or f.field.group_id == None)] def fields_by_group(self): - return [(g, self.get_fields_in_group(g)) for g in self.groups] + [(None, self.get_no_group_fields())] + return [(g, self.get_fields_in_group(g)) for g in self.groups] + [(GroupFormMixin.FieldGroup("other", _("Other")), self.get_no_group_fields())] def clean(self): result = super().clean() @@ -189,6 +189,7 @@ class EventForm(GroupFormMixin, ModelForm): old_local_image = CharField(widget=HiddenInput(), required=False) simple_cloning = CharField(widget=HiddenInput(), required=False) + cloning = CharField(widget=HiddenInput(), required=False) tags = MultipleChoiceField( label=_("Tags"), @@ -205,7 +206,11 @@ class EventForm(GroupFormMixin, ModelForm): "modified_date", "moderated_date", "import_sources", - "image" + "image", + "moderated_by_user", + "modified_by_user", + "created_by_user", + "imported_by_user" ] widgets = { "start_day": TextInput( @@ -363,6 +368,9 @@ class EventModerateForm(ModelForm): "exact_location", "tags" ] + widgets = { + "status": RadioSelect + } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -493,7 +501,7 @@ class SelectEventInList(Form): super().__init__(*args, **kwargs) self.fields["event"].choices = [ - (e.pk, str(e.start_day) + " " + e.title + ((", " + e.location) if e.location else "")) for e in events + (e.pk, (e.start_time.strftime('%H:%M') + " : " if e.start_time else "") + e.title + ((", " + e.location) if e.location else "")) for e in events ] @@ -541,17 +549,17 @@ class MergeDuplicates(Form): '
  • ' + e.title + "
  • " ) result += ( - "
  • Création : " + localize(localtime(e.created_date)) + "
  • " + "
  • Création : " + localize(e.created_date) + "
  • " ) result += ( "
  • Dernière modification : " - + localize(localtime(e.modified_date)) + + localize(e.modified_date) + "
  • " ) if e.imported_date: result += ( "
  • Dernière importation : " - + localize(localtime(e.imported_date)) + + localize(e.imported_date) + "
  • " ) result += "" @@ -602,7 +610,7 @@ class MergeDuplicates(Form): result += '
    ' + result = ('
    ' + super().as_p() - + '
    ' - + '

    Cliquez pour ajuster la position GPS

    ' - ) + + '''
    +
    +

    Cliquez pour ajuster la position GPS

    + Verrouiller la position + +
    ''') + + return mark_safe(result) def apply(self): return self.cleaned_data.get("apply_to_all") -class ContactMessageForm(ModelForm): +class MessageForm(ModelForm): class Meta: - model = ContactMessage + model = Message fields = ["subject", "name", "email", "message", "related_event"] - widgets = {"related_event": HiddenInput()} + widgets = {"related_event": HiddenInput(), "user": HiddenInput() } def __init__(self, *args, **kwargs): self.event = kwargs.pop("event", False) + self.internal = kwargs.pop("internal", False) super().__init__(*args, **kwargs) + self.fields['related_event'].required = False + if self.internal: + self.fields.pop("name") + self.fields.pop("email") + +class MessageEventForm(ModelForm): + + class Meta: + model = Message + fields = ["message"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["message"].label = _("Add a comment") \ No newline at end of file diff --git a/src/agenda_culturel/import_tasks/custom_extractors/fbevents.py b/src/agenda_culturel/import_tasks/custom_extractors/fbevents.py index b9e3b32..d6be501 100644 --- a/src/agenda_culturel/import_tasks/custom_extractors/fbevents.py +++ b/src/agenda_culturel/import_tasks/custom_extractors/fbevents.py @@ -3,6 +3,12 @@ from ..extractor_facebook import FacebookEvent import json5 from bs4 import BeautifulSoup import json +import os +from datetime import datetime + +import logging + +logger = logging.getLogger(__name__) # A class dedicated to get events from a facebook events page @@ -13,10 +19,27 @@ class CExtractor(TwoStepsExtractor): def build_event_url_list(self, content): soup = BeautifulSoup(content, "html.parser") + debug = False + + found = False links = soup.find_all("a") for link in links: if link.get("href").startswith('https://www.facebook.com/events/'): self.add_event_url(link.get('href').split('?')[0]) + found = True + + if not found and debug: + directory = "errors/" + if not os.path.exists(directory): + os.makedirs(directory) + now = datetime.now() + filename = directory + now.strftime("%Y%m%d_%H%M%S") + ".html" + logger.warning("cannot find any event link in events page. Save content page in " + filename) + with open(filename, "w") as text_file: + text_file.write("\n\n") + text_file.write(content) + + def add_event_from_content( @@ -42,4 +65,7 @@ class CExtractor(TwoStepsExtractor): event["published"] = published self.add_event(default_values, **event) + else: + logger.warning("cannot find any event in page") + diff --git a/src/agenda_culturel/import_tasks/custom_extractors/lerio.py b/src/agenda_culturel/import_tasks/custom_extractors/lerio.py new file mode 100644 index 0000000..117693c --- /dev/null +++ b/src/agenda_culturel/import_tasks/custom_extractors/lerio.py @@ -0,0 +1,91 @@ +from ..generic_extractors import * +from bs4 import BeautifulSoup +from datetime import datetime + +# A class dedicated to get events from Cinéma Le Rio (Clermont-Ferrand) +# URL: https://www.cinemalerio.com/evenements/ +class CExtractor(TwoStepsExtractorNoPause): + + def __init__(self): + super().__init__() + self.possible_dates = {} + self.theater = None + + def build_event_url_list(self, content, infuture_days=180): + + soup = BeautifulSoup(content, "html.parser") + + links = soup.select("td.seance_link a") + if links: + for l in links: + print(l["href"]) + self.add_event_url(l["href"]) + + def to_text_select_one(soup, filter): + e = soup.select_one(filter) + if e is None: + return None + else: + return e.text + + def add_event_from_content( + self, + event_content, + event_url, + url_human=None, + default_values=None, + published=False, + ): + + soup = BeautifulSoup(event_content, "html.parser") + + title = soup.select_one("h1").text + + alerte_date = CExtractor.to_text_select_one(soup, ".alerte_date") + if alerte_date is None: + return + dh = alerte_date.split("à") + # if date is not found, we skip + if len(dh) != 2: + return + + date = Extractor.parse_french_date(dh[0], default_year=datetime.now().year) + time = Extractor.parse_french_time(dh[1]) + + synopsis = CExtractor.to_text_select_one(soup, ".synopsis_bloc") + special_titre = CExtractor.to_text_select_one(soup, ".alerte_titre") + special = CExtractor.to_text_select_one(soup, ".alerte_text") + + # it's not a specific event: we skip it + special_lines = None if special is None else special.split('\n') + if special is None or len(special_lines) == 0 or \ + (len(special_lines) == 1 and special_lines[0].strip().startswith('En partenariat')): + return + + description = "\n\n".join([x for x in [synopsis, special_titre, special] if not x is None]) + + image = soup.select_one(".col1 img") + image_alt = None + if not image is None: + image_alt = image["alt"] + image = image["src"] + + self.add_event_with_props( + default_values, + event_url, + title, + None, + date, + None, + description, + [], + recurrences=None, + uuids=[event_url], + url_human=event_url, + start_time=time, + end_day=None, + end_time=None, + published=published, + image=image, + image_alt=image_alt + ) \ No newline at end of file diff --git a/src/agenda_culturel/import_tasks/downloader.py b/src/agenda_culturel/import_tasks/downloader.py index 7fd45ee..905c130 100644 --- a/src/agenda_culturel/import_tasks/downloader.py +++ b/src/agenda_culturel/import_tasks/downloader.py @@ -66,7 +66,7 @@ class SimpleDownloader(Downloader): class ChromiumHeadlessDownloader(Downloader): - def __init__(self, pause=True): + def __init__(self, pause=True, noimage=True): super().__init__() self.pause = pause self.options = Options() @@ -78,17 +78,31 @@ class ChromiumHeadlessDownloader(Downloader): self.options.add_argument("--disable-dev-shm-usage") self.options.add_argument("--disable-browser-side-navigation") self.options.add_argument("--disable-gpu") - self.options.add_experimental_option( - "prefs", { - # block image loading - "profile.managed_default_content_settings.images": 2, - } - ) + if noimage: + self.options.add_experimental_option( + "prefs", { + # block image loading + "profile.managed_default_content_settings.images": 2, + } + ) self.service = Service("/usr/bin/chromedriver") self.driver = webdriver.Chrome(service=self.service, options=self.options) + def screenshot(self, url, path_image): + print("Screenshot {}".format(url)) + try: + self.driver.get(url) + if self.pause: + time.sleep(2) + self.driver.save_screenshot(path_image) + except: + print(f">> Exception: {URL}") + return False + + return True + def download(self, url, referer=None, post=None): if post: raise Exception("POST method with Chromium headless not yet implemented") diff --git a/src/agenda_culturel/import_tasks/extractor.py b/src/agenda_culturel/import_tasks/extractor.py index 3e75870..3ce362b 100644 --- a/src/agenda_culturel/import_tasks/extractor.py +++ b/src/agenda_culturel/import_tasks/extractor.py @@ -49,7 +49,7 @@ class Extractor(ABC): return i + 1 return None - def parse_french_date(text): + def parse_french_date(text, default_year=None): # format NomJour Numero Mois Année m = re.search( "[a-zA-ZéÉûÛ:.]+[ ]*([0-9]+)[er]*[ ]*([a-zA-ZéÉûÛ:.]+)[ ]*([0-9]+)", text @@ -73,8 +73,15 @@ class Extractor(ABC): month = int(m.group(2)) year = m.group(3) else: - # TODO: consolider les cas non satisfaits - return None + # format Numero Mois Annee + m = re.search("([0-9]+)[er]*[ ]*([a-zA-ZéÉûÛ:.]+)", text) + if m: + day = m.group(1) + month = Extractor.guess_month(m.group(2)) + year = default_year + else: + # TODO: consolider les cas non satisfaits + return None if month is None: return None @@ -254,9 +261,9 @@ class EventNotFoundExtractor(Extractor): self.set_header(url) self.clear_events() - self.add_event(default_values, "événement sans titre", + self.add_event(default_values, "événement sans titre depuis " + url, None, timezone.now().date(), None, - "l'import a échoué, la saisie doit se faire manuellement à partir de l'url source", + "l'import a échoué, la saisie doit se faire manuellement à partir de l'url source " + url, [], [url], published=False, url_human=url) return self.get_structure() diff --git a/src/agenda_culturel/import_tasks/extractor_facebook.py b/src/agenda_culturel/import_tasks/extractor_facebook.py index e0c50b6..b7970ab 100644 --- a/src/agenda_culturel/import_tasks/extractor_facebook.py +++ b/src/agenda_culturel/import_tasks/extractor_facebook.py @@ -239,7 +239,7 @@ class FacebookEventExtractor(Extractor): result = "https://www.facebook.com" + u.path # remove name in the url - match = re.match(r"(.*/events)/s/([a-zA-Z-][a-zA-Z-0-9]+)/([0-9/]*)", result) + match = re.match(r"(.*/events)/s/([a-zA-Z-][a-zA-Z-0-9-]+)/([0-9/]*)", result) if match: result = match[1] + "/" + match[3] diff --git a/src/agenda_culturel/import_tasks/generic_extractors.py b/src/agenda_culturel/import_tasks/generic_extractors.py index 10803c9..14acc44 100644 --- a/src/agenda_culturel/import_tasks/generic_extractors.py +++ b/src/agenda_culturel/import_tasks/generic_extractors.py @@ -264,7 +264,10 @@ class TwoStepsExtractorNoPause(TwoStepsExtractor): only_future=True, ignore_404=True ): - pause = self.downloader.pause + if hasattr(self.downloader, "pause"): + pause = self.downloader.pause + else: + pause = False self.downloader.pause = False result = super().extract(content, url, url_human, default_values, published, only_future, ignore_404) self.downloader.pause = pause diff --git a/src/agenda_culturel/locale/fr/LC_MESSAGES/django.po b/src/agenda_culturel/locale/fr/LC_MESSAGES/django.po index 20cb064..a315b39 100644 --- a/src/agenda_culturel/locale/fr/LC_MESSAGES/django.po +++ b/src/agenda_culturel/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: agenda_culturel\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-11-28 00:09+0100\n" +"POT-Creation-Date: 2024-12-22 15:52+0100\n" "PO-Revision-Date: 2023-10-29 14:16+0000\n" "Last-Translator: Jean-Marie Favreau \n" "Language-Team: Jean-Marie Favreau \n" @@ -17,76 +17,76 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: agenda_culturel/calendar.py:151 +#: agenda_culturel/calendar.py:168 msgid "All day today" msgstr "Aujourd'hui toute la journée" -#: agenda_culturel/calendar.py:152 +#: agenda_culturel/calendar.py:169 msgid "This morning" msgstr "Ce matin" -#: agenda_culturel/calendar.py:152 +#: agenda_culturel/calendar.py:169 msgid "This noon" msgstr "Ce midi" -#: agenda_culturel/calendar.py:152 +#: agenda_culturel/calendar.py:169 msgid "This afternoon" msgstr "Cet après-midi" -#: agenda_culturel/calendar.py:152 +#: agenda_culturel/calendar.py:169 msgid "This evening" msgstr "Ce soir" -#: agenda_culturel/calendar.py:154 +#: agenda_culturel/calendar.py:171 msgid "Tomorrow" msgstr "Demain" -#: agenda_culturel/calendar.py:155 +#: agenda_culturel/calendar.py:172 msgid "All day tomorrow" msgstr "Toute la journée de demain" -#: agenda_culturel/calendar.py:156 agenda_culturel/calendar.py:160 +#: agenda_culturel/calendar.py:173 agenda_culturel/calendar.py:177 #, python-format msgid "%s morning" msgstr "%s matin" -#: agenda_culturel/calendar.py:156 agenda_culturel/calendar.py:160 +#: agenda_culturel/calendar.py:173 agenda_culturel/calendar.py:177 #, python-format msgid "%s noon" msgstr "%s midi" -#: agenda_culturel/calendar.py:156 agenda_culturel/calendar.py:160 +#: agenda_culturel/calendar.py:173 agenda_culturel/calendar.py:177 #, python-format msgid "%s afternoon" msgstr "%s après-midi" -#: agenda_culturel/calendar.py:156 agenda_culturel/calendar.py:160 +#: agenda_culturel/calendar.py:173 agenda_culturel/calendar.py:177 #, python-format msgid "%s evening" msgstr "%s soir" -#: agenda_culturel/calendar.py:159 +#: agenda_culturel/calendar.py:176 #, python-format msgid "All day %s" msgstr "Toute la journée de %s" -#: agenda_culturel/calendar.py:161 +#: agenda_culturel/calendar.py:178 msgid "All day" msgstr "Toute la journée" -#: agenda_culturel/calendar.py:162 +#: agenda_culturel/calendar.py:179 msgid "Morning" msgstr "Matin" -#: agenda_culturel/calendar.py:162 +#: agenda_culturel/calendar.py:179 msgid "Noon" msgstr "Midi" -#: agenda_culturel/calendar.py:162 +#: agenda_culturel/calendar.py:179 msgid "Afternoon" msgstr "Après-midi" -#: agenda_culturel/calendar.py:162 +#: agenda_culturel/calendar.py:179 msgid "Evening" msgstr "Soir" @@ -94,42 +94,46 @@ msgstr "Soir" msgid "Select a location" msgstr "Choisir une localité" -#: agenda_culturel/filters.py:326 +#: agenda_culturel/filters.py:331 msgid "Representative version" msgstr "Version représentative" -#: agenda_culturel/filters.py:327 +#: agenda_culturel/filters.py:332 msgid "Yes" msgstr "Oui" -#: agenda_culturel/filters.py:327 +#: agenda_culturel/filters.py:332 msgid "Non" msgstr "Non" -#: agenda_culturel/filters.py:332 +#: agenda_culturel/filters.py:337 msgid "Imported from" msgstr "Importé depuis" -#: agenda_culturel/filters.py:362 agenda_culturel/models.py:1728 +#: agenda_culturel/filters.py:367 agenda_culturel/models.py:1852 msgid "Closed" msgstr "Fermé" -#: agenda_culturel/filters.py:362 +#: agenda_culturel/filters.py:367 msgid "Open" msgstr "Ouvert" -#: agenda_culturel/filters.py:367 agenda_culturel/models.py:1722 +#: agenda_culturel/filters.py:372 agenda_culturel/models.py:1846 msgid "Spam" msgstr "Spam" -#: agenda_culturel/filters.py:367 +#: agenda_culturel/filters.py:372 msgid "Non spam" msgstr "Non spam" -#: agenda_culturel/filters.py:378 +#: agenda_culturel/filters.py:383 msgid "Search" msgstr "Rechercher" +#: agenda_culturel/forms.py:77 +msgid "Other" +msgstr "Autres" + #: agenda_culturel/forms.py:107 msgid "Name of new tag" msgstr "Nom de la nouvelle étiquette" @@ -141,68 +145,68 @@ msgstr "" "Forcer le renommage malgré l'existence d'événements utilisant déjà " "l'étiquette choisie." -#: agenda_culturel/forms.py:133 agenda_culturel/models.py:175 -#: agenda_culturel/models.py:574 agenda_culturel/models.py:1843 -#: agenda_culturel/models.py:1948 +#: agenda_culturel/forms.py:133 agenda_culturel/models.py:178 +#: agenda_culturel/models.py:620 agenda_culturel/models.py:1977 +#: agenda_culturel/models.py:2087 msgid "Category" msgstr "Catégorie" #: agenda_culturel/forms.py:139 agenda_culturel/forms.py:164 -#: agenda_culturel/forms.py:194 agenda_culturel/forms.py:338 -#: agenda_culturel/models.py:216 agenda_culturel/models.py:682 +#: agenda_culturel/forms.py:194 agenda_culturel/forms.py:349 +#: agenda_culturel/models.py:219 agenda_culturel/models.py:728 msgid "Tags" msgstr "Étiquettes" -#: agenda_culturel/forms.py:246 +#: agenda_culturel/forms.py:250 msgid "Main fields" msgstr "Champs principaux" -#: agenda_culturel/forms.py:249 +#: agenda_culturel/forms.py:253 msgid "Start of event" msgstr "Début de l'événement" -#: agenda_culturel/forms.py:253 +#: agenda_culturel/forms.py:257 msgid "End of event" msgstr "Fin de l'événement" -#: agenda_culturel/forms.py:257 +#: agenda_culturel/forms.py:262 msgid "This is a recurring event" msgstr "Cet événement est récurrent" -#: agenda_culturel/forms.py:260 +#: agenda_culturel/forms.py:271 msgid "Details" msgstr "Détails" -#: agenda_culturel/forms.py:265 agenda_culturel/models.py:604 -#: agenda_culturel/models.py:1824 +#: agenda_culturel/forms.py:276 agenda_culturel/models.py:650 +#: agenda_culturel/models.py:1952 msgid "Location" msgstr "Localisation" -#: agenda_culturel/forms.py:269 agenda_culturel/models.py:637 +#: agenda_culturel/forms.py:280 agenda_culturel/models.py:683 msgid "Illustration" msgstr "Illustration" -#: agenda_culturel/forms.py:275 agenda_culturel/forms.py:280 +#: agenda_culturel/forms.py:286 agenda_culturel/forms.py:291 msgid "Meta information" msgstr "Méta-informations" -#: agenda_culturel/forms.py:295 +#: agenda_culturel/forms.py:306 msgid "The end date must be after the start date." msgstr "La date de fin doit être après la date de début." -#: agenda_culturel/forms.py:311 +#: agenda_culturel/forms.py:322 msgid "The end time cannot be earlier than the start time." msgstr "L'heure de fin ne peut pas être avant l'heure de début." -#: agenda_culturel/forms.py:339 +#: agenda_culturel/forms.py:350 msgid "Select tags from existing ones." msgstr "Sélectionner des étiquettes depuis celles existantes." -#: agenda_culturel/forms.py:344 +#: agenda_culturel/forms.py:355 msgid "New tags" msgstr "Nouvelles étiquettes" -#: agenda_culturel/forms.py:345 +#: agenda_culturel/forms.py:356 msgid "" "Create new labels (sparingly). Note: by starting your tag with the " "characters “TW:”, youll create a “trigger warning” tag, and the associated " @@ -213,156 +217,176 @@ msgstr "" "étiquette “trigger warning”, et les événements associés seront annoncés " "comme tels." -#: agenda_culturel/forms.py:388 +#: agenda_culturel/forms.py:402 msgid "JSON in the format expected for the import." msgstr "JSON dans le format attendu pour l'import" -#: agenda_culturel/forms.py:410 +#: agenda_culturel/forms.py:424 msgid " (locally modified version)" msgstr " (version modifiée localement)" -#: agenda_culturel/forms.py:414 +#: agenda_culturel/forms.py:428 msgid " (synchronized on import version)" msgstr " (version synchronisée sur l'import)" -#: agenda_culturel/forms.py:418 +#: agenda_culturel/forms.py:432 msgid "Select {} as representative version." msgstr "Sélectionner {} comme version représentative" -#: agenda_culturel/forms.py:427 +#: agenda_culturel/forms.py:441 msgid "Update {} using some fields from other versions (interactive mode)." msgstr "" "Mettre à jour {} en utilisant quelques champs des autres versions (mode " "interactif)." -#: agenda_culturel/forms.py:434 +#: agenda_culturel/forms.py:448 msgid " Warning: a version is already locally modified." msgstr " Attention: une version a déjà été modifiée localement." -#: agenda_culturel/forms.py:439 +#: agenda_culturel/forms.py:453 msgid "Create a new version by merging (interactive mode)." msgstr "Créer une nouvelle version par fusion (mode interactif)." -#: agenda_culturel/forms.py:446 +#: agenda_culturel/forms.py:460 msgid "Make {} independent." msgstr "Rendre {} indépendant." -#: agenda_culturel/forms.py:448 +#: agenda_culturel/forms.py:462 msgid "Make all versions independent." msgstr "Rendre toutes les versions indépendantes." -#: agenda_culturel/forms.py:482 agenda_culturel/models.py:762 +#: agenda_culturel/forms.py:496 agenda_culturel/models.py:812 msgid "Event" msgstr "Événement" -#: agenda_culturel/forms.py:507 +#: agenda_culturel/forms.py:521 msgid "Value of the selected version" msgstr "Valeur de la version sélectionnée" -#: agenda_culturel/forms.py:509 agenda_culturel/forms.py:513 +#: agenda_culturel/forms.py:523 agenda_culturel/forms.py:527 msgid "Value of version {}" msgstr "Valeur de la version {}" -#: agenda_culturel/forms.py:662 +#: agenda_culturel/forms.py:676 msgid "Apply category {} to the event {}" msgstr "Appliquer la catégorie {} à l'événement {}" -#: agenda_culturel/forms.py:679 agenda_culturel/models.py:458 -#: agenda_culturel/models.py:2000 +#: agenda_culturel/forms.py:693 agenda_culturel/models.py:466 +#: agenda_culturel/models.py:2139 msgid "Place" msgstr "Lieu" -#: agenda_culturel/forms.py:681 +#: agenda_culturel/forms.py:695 msgid "Create a missing place" msgstr "Créer un lieu manquant" -#: agenda_culturel/forms.py:691 +#: agenda_culturel/forms.py:705 msgid "Add \"{}\" to the aliases of the place" msgstr "Ajouter « {} » aux alias du lieu" -#: agenda_culturel/forms.py:720 +#: agenda_culturel/forms.py:734 msgid "On saving, use aliases to detect all matching events with missing place" msgstr "" "Lors de l'enregistrement, utiliser des alias pour détecter tous les " "événements correspondants dont la place est manquante." -#: agenda_culturel/models.py:56 agenda_culturel/models.py:104 -#: agenda_culturel/models.py:185 agenda_culturel/models.py:402 -#: agenda_culturel/models.py:430 agenda_culturel/models.py:511 -#: agenda_culturel/models.py:1704 agenda_culturel/models.py:1778 +#: agenda_culturel/forms.py:747 +msgid "Header" +msgstr "Entête" + +#: agenda_culturel/forms.py:751 agenda_culturel/models.py:439 +msgid "Address" +msgstr "Adresse" + +#: agenda_culturel/forms.py:757 +msgid "Meta" +msgstr "Méta" + +#: agenda_culturel/forms.py:760 +msgid "Information" +msgstr "Informations" + +#: agenda_culturel/forms.py:810 +msgid "Add a comment" +msgstr "Ajouter un commentaire" + +#: agenda_culturel/models.py:59 agenda_culturel/models.py:107 +#: agenda_culturel/models.py:188 agenda_culturel/models.py:409 +#: agenda_culturel/models.py:437 agenda_culturel/models.py:524 +#: agenda_culturel/models.py:1828 agenda_culturel/models.py:1906 msgid "Name" msgstr "Nom" -#: agenda_culturel/models.py:57 agenda_culturel/models.py:104 +#: agenda_culturel/models.py:60 agenda_culturel/models.py:107 msgid "Category name" msgstr "Nom de la catégorie" -#: agenda_culturel/models.py:62 +#: agenda_culturel/models.py:65 msgid "Content" msgstr "Contenu" -#: agenda_culturel/models.py:62 +#: agenda_culturel/models.py:65 msgid "Text as shown to the visitors" msgstr "Texte tel que présenté aux visiteureuses" -#: agenda_culturel/models.py:66 +#: agenda_culturel/models.py:69 msgid "URL path" msgstr "Chemin URL" -#: agenda_culturel/models.py:67 +#: agenda_culturel/models.py:70 msgid "URL path where the content is included." msgstr "Chemin URL où le contenu est présent." -#: agenda_culturel/models.py:71 +#: agenda_culturel/models.py:74 msgid "Static content" msgstr "Contenu statique" -#: agenda_culturel/models.py:72 +#: agenda_culturel/models.py:75 msgid "Static contents" msgstr "Contenus statiques" -#: agenda_culturel/models.py:108 +#: agenda_culturel/models.py:111 msgid "Color" msgstr "Couleur" -#: agenda_culturel/models.py:109 +#: agenda_culturel/models.py:112 msgid "Color used as background for the category" msgstr "Couleur utilisée comme fond de la catégorie" -#: agenda_culturel/models.py:115 +#: agenda_culturel/models.py:118 msgid "Pictogram" msgstr "Pictogramme" -#: agenda_culturel/models.py:116 +#: agenda_culturel/models.py:119 msgid "Pictogram of the category (svg format)" msgstr "Pictogramme de la catégorie (format svg)" -#: agenda_culturel/models.py:123 +#: agenda_culturel/models.py:126 msgid "Position for ordering categories" msgstr "Position pour ordonner les catégories" -#: agenda_culturel/models.py:176 +#: agenda_culturel/models.py:179 msgid "Categories" msgstr "Catégories" -#: agenda_culturel/models.py:185 +#: agenda_culturel/models.py:188 msgid "Tag name" msgstr "Nom de l'étiquette" -#: agenda_culturel/models.py:190 agenda_culturel/models.py:441 -#: agenda_culturel/models.py:523 agenda_culturel/models.py:621 +#: agenda_culturel/models.py:193 agenda_culturel/models.py:449 +#: agenda_culturel/models.py:536 agenda_culturel/models.py:667 msgid "Description" msgstr "Description" -#: agenda_culturel/models.py:191 +#: agenda_culturel/models.py:194 msgid "Description of the tag" msgstr "Description de l'étiquette" -#: agenda_culturel/models.py:197 +#: agenda_culturel/models.py:200 msgid "Principal" msgstr "Principal" -#: agenda_culturel/models.py:198 +#: agenda_culturel/models.py:201 msgid "" "This tag is highlighted as a main tag for visitors, particularly in the " "filter." @@ -370,107 +394,115 @@ msgstr "" "Cette étiquette est mise en avant comme étiquette principale pour les " "visiteurs, en particulier dans le filtre." -#: agenda_culturel/models.py:203 +#: agenda_culturel/models.py:206 msgid "In excluded suggestions" msgstr "Dans les suggestions d'exclusion" -#: agenda_culturel/models.py:204 +#: agenda_culturel/models.py:207 msgid "This tag will be part of the excluded suggestions." msgstr "Cette étiquette fera partie des suggestions d'exclusion." -#: agenda_culturel/models.py:209 +#: agenda_culturel/models.py:212 msgid "In included suggestions" msgstr "Dans les suggestions d'inclusion." -#: agenda_culturel/models.py:210 +#: agenda_culturel/models.py:213 msgid "This tag will be part of the included suggestions." msgstr "Cette étiquette fera partie des suggestions d'inclusion." -#: agenda_culturel/models.py:215 +#: agenda_culturel/models.py:218 msgid "Tag" msgstr "Étiquette" -#: agenda_culturel/models.py:261 +#: agenda_culturel/models.py:264 msgid "Suggestions" msgstr "Suggestions" -#: agenda_culturel/models.py:262 +#: agenda_culturel/models.py:265 msgid "Others" msgstr "Autres" -#: agenda_culturel/models.py:277 +#: agenda_culturel/models.py:280 msgid "Representative event" msgstr "Événement représentatif" -#: agenda_culturel/models.py:278 +#: agenda_culturel/models.py:281 msgid "This event is the representative event of the duplicated events group" msgstr "" "Cet événement est l'événement représentatif du groupe d'événements dupliqués." -#: agenda_culturel/models.py:285 agenda_culturel/models.py:286 +#: agenda_culturel/models.py:288 agenda_culturel/models.py:289 msgid "Duplicated events" msgstr "Événements dupliqués" -#: agenda_culturel/models.py:402 +#: agenda_culturel/models.py:409 msgid "Name of the location" msgstr "Nom de la position" -#: agenda_culturel/models.py:405 +#: agenda_culturel/models.py:412 msgid "Main" msgstr "Principale" -#: agenda_culturel/models.py:406 +#: agenda_culturel/models.py:413 msgid "This location is one of the main locations (shown first higher values)." msgstr "" "Cette position est une position principale (affichage en premier des plus " "grandes valeurs)." -#: agenda_culturel/models.py:410 +#: agenda_culturel/models.py:417 msgid "Suggested distance (km)" msgstr "" -#: agenda_culturel/models.py:411 +#: agenda_culturel/models.py:418 msgid "" "If this distance is given, this location is part of the suggested filters." msgstr "" -#: agenda_culturel/models.py:417 +#: agenda_culturel/models.py:424 msgid "Reference location" msgstr "Position de référence" -#: agenda_culturel/models.py:418 +#: agenda_culturel/models.py:425 msgid "Reference locations" msgstr "Positions de référence" -#: agenda_culturel/models.py:430 +#: agenda_culturel/models.py:437 msgid "Name of the place" msgstr "Nom du lieu" -#: agenda_culturel/models.py:432 -msgid "Address" -msgstr "Adresse" - -#: agenda_culturel/models.py:433 +#: agenda_culturel/models.py:440 msgid "Address of this place (without city name)" msgstr "Adresse de ce lieu (sans le nom de la ville)" -#: agenda_culturel/models.py:437 +#: agenda_culturel/models.py:444 +msgid "Postcode" +msgstr "Code postal" + +#: agenda_culturel/models.py:444 +msgid "" +"The post code is not displayed, but makes it easier to find an address when " +"you enter it." +msgstr "" +"Le code postal ne sera pas affiché, mais facilite la recherche d'adresse au " +"moment de la saisie." + +#: agenda_culturel/models.py:445 msgid "City" msgstr "Ville" -#: agenda_culturel/models.py:437 +#: agenda_culturel/models.py:445 msgid "City name" msgstr "Nom de la ville" -#: agenda_culturel/models.py:442 +#: agenda_culturel/models.py:450 msgid "Description of the place, including accessibility." msgstr "Description du lieu, inclus l'accessibilité." -#: agenda_culturel/models.py:449 +#: agenda_culturel/models.py:457 msgid "Alternative names" msgstr "Noms alternatifs" -#: agenda_culturel/models.py:451 +#: agenda_culturel/models.py:459 msgid "" "Alternative names or addresses used to match a place with the free-form " "location of an event." @@ -478,31 +510,31 @@ msgstr "" "Noms et adresses alternatives qui seront utilisées pour associer une adresse " "avec la localisation en forme libre d'un événement" -#: agenda_culturel/models.py:459 +#: agenda_culturel/models.py:467 msgid "Places" msgstr "Lieux" -#: agenda_culturel/models.py:511 +#: agenda_culturel/models.py:524 msgid "Organisation name" msgstr "Nom de l'organisme" -#: agenda_culturel/models.py:515 +#: agenda_culturel/models.py:528 msgid "Website" msgstr "Site internet" -#: agenda_culturel/models.py:516 +#: agenda_culturel/models.py:529 msgid "Website of the organisation" msgstr "Site internet de l'organisme" -#: agenda_culturel/models.py:524 +#: agenda_culturel/models.py:537 msgid "Description of the organisation." msgstr "Description de l'organisme" -#: agenda_culturel/models.py:531 +#: agenda_culturel/models.py:544 msgid "Principal place" msgstr "Lieu principal" -#: agenda_culturel/models.py:532 +#: agenda_culturel/models.py:545 msgid "" "Place mainly associated with this organizer. Mainly used if there is a " "similarity in the name, to avoid redundant displays." @@ -510,59 +542,75 @@ msgstr "" "Lieu principalement associé à cet organisateur. Principalement utilisé s'il " "y a une similarité de nom, pour éviter les affichages redondants." -#: agenda_culturel/models.py:539 +#: agenda_culturel/models.py:552 msgid "Organisation" msgstr "Organisme" -#: agenda_culturel/models.py:540 +#: agenda_culturel/models.py:553 msgid "Organisations" msgstr "Organismes" -#: agenda_culturel/models.py:552 agenda_culturel/models.py:1819 +#: agenda_culturel/models.py:565 agenda_culturel/models.py:1947 msgid "Published" msgstr "Publié" -#: agenda_culturel/models.py:553 +#: agenda_culturel/models.py:566 msgid "Draft" msgstr "Brouillon" -#: agenda_culturel/models.py:554 +#: agenda_culturel/models.py:567 msgid "Trash" msgstr "Corbeille" -#: agenda_culturel/models.py:565 +#: agenda_culturel/models.py:576 +msgid "Author of the event creation" +msgstr "Auteur de la création de l'événement" + +#: agenda_culturel/models.py:584 +msgid "Author of the last importation" +msgstr "Auteur de la dernière importation" + +#: agenda_culturel/models.py:592 +msgid "Author of the last modification" +msgstr "Auteur de la dernière modification" + +#: agenda_culturel/models.py:600 +msgid "Author of the last moderation" +msgstr "Auteur de la dernière modération" + +#: agenda_culturel/models.py:611 msgid "Title" msgstr "Titre" -#: agenda_culturel/models.py:569 agenda_culturel/models.py:1916 +#: agenda_culturel/models.py:615 agenda_culturel/models.py:2055 msgid "Status" msgstr "Status" -#: agenda_culturel/models.py:581 +#: agenda_culturel/models.py:627 msgid "Start day" msgstr "Date de début" -#: agenda_culturel/models.py:584 +#: agenda_culturel/models.py:630 msgid "Start time" msgstr "Heure de début" -#: agenda_culturel/models.py:590 +#: agenda_culturel/models.py:636 msgid "End day" msgstr "Date de fin" -#: agenda_culturel/models.py:595 +#: agenda_culturel/models.py:641 msgid "End time" msgstr "Heure de fin" -#: agenda_culturel/models.py:599 +#: agenda_culturel/models.py:645 msgid "Recurrence" msgstr "Récurrence" -#: agenda_culturel/models.py:610 +#: agenda_culturel/models.py:656 msgid "Location (free form)" msgstr "Localisation (forme libre)" -#: agenda_culturel/models.py:612 +#: agenda_culturel/models.py:658 msgid "" "Address of the event in case its not available in the already known places " "(free form)" @@ -570,11 +618,11 @@ msgstr "" "Addresse d'un événement si elle n'est pas déjà présente dans la liste des " "lieux disponible (forme libre)" -#: agenda_culturel/models.py:629 +#: agenda_culturel/models.py:675 msgid "Organisers" msgstr "Organisateurs" -#: agenda_culturel/models.py:631 +#: agenda_culturel/models.py:677 msgid "" "list of event organisers. Organizers will only be displayed if one of them " "does not normally use the venue." @@ -582,188 +630,207 @@ msgstr "" "Liste des organisateurs de l'événements. Les organisateurs seront affichés " "uniquement si au moins un d'entre eux n'utilise pas habituellement le lieu." -#: agenda_culturel/models.py:644 +#: agenda_culturel/models.py:690 msgid "Illustration (URL)" msgstr "Illustration (URL)" -#: agenda_culturel/models.py:645 +#: agenda_culturel/models.py:691 msgid "External URL of the illustration image" msgstr "URL externe de l'image illustrative" -#: agenda_culturel/models.py:651 +#: agenda_culturel/models.py:697 msgid "Illustration description" msgstr "Description de l'illustration" -#: agenda_culturel/models.py:652 +#: agenda_culturel/models.py:698 msgid "Alternative text used by screen readers for the image" msgstr "Texte alternatif utiliser par les lecteurs d'écrans pour l'image" -#: agenda_culturel/models.py:660 +#: agenda_culturel/models.py:706 msgid "Importation source" msgstr "Source d'importation" -#: agenda_culturel/models.py:661 +#: agenda_culturel/models.py:707 msgid "Importation source used to detect removed entries." msgstr "Source d'importation utilisée pour détecter les éléments supprimés/" -#: agenda_culturel/models.py:667 +#: agenda_culturel/models.py:713 msgid "UUIDs" msgstr "UUIDs" -#: agenda_culturel/models.py:668 +#: agenda_culturel/models.py:714 msgid "UUIDs from import to detect duplicated entries." msgstr "UUIDs utilisés pendant l'import pour détecter les entrées dupliquées" -#: agenda_culturel/models.py:674 +#: agenda_culturel/models.py:720 msgid "URLs" msgstr "URLs" -#: agenda_culturel/models.py:675 +#: agenda_culturel/models.py:721 msgid "List of all the urls where this event can be found." msgstr "Liste de toutes les urls où l'événement peut être trouvé." -#: agenda_culturel/models.py:689 +#: agenda_culturel/models.py:735 msgid "Other versions" msgstr "" -#: agenda_culturel/models.py:763 +#: agenda_culturel/models.py:813 msgid "Events" msgstr "Événements" -#: agenda_culturel/models.py:1685 -msgid "Contact message" -msgstr "Message de contact" +#: agenda_culturel/models.py:1597 +msgid "" +"The duration of the event is a little too long for direct publication. " +"Moderators can choose to publish it or not." +msgstr "" +"La durée de l'événement est un peu trop longue pour qu'il soit publié " +"directement. Les modérateurs peuvent choisir de le publier ou non." -#: agenda_culturel/models.py:1686 -msgid "Contact messages" -msgstr "Messages de contact" +#: agenda_culturel/models.py:1606 +msgid "Import" +msgstr "Import" -#: agenda_culturel/models.py:1689 -msgid "Subject" -msgstr "Sujet" +#: agenda_culturel/models.py:1606 +msgid "import process" +msgstr "processus d'import" -#: agenda_culturel/models.py:1690 -msgid "The subject of your message" -msgstr "Sujet de votre message" - -#: agenda_culturel/models.py:1696 -#| msgid "Duplicated events" -msgid "Related event" -msgstr "Événement associé" - -#: agenda_culturel/models.py:1697 -msgid "The message is associated with this event." -msgstr "Le message est associé à cet événement." - -#: agenda_culturel/models.py:1705 -msgid "Your name" -msgstr "Votre nom" - -#: agenda_culturel/models.py:1711 -msgid "Email address" -msgstr "Adresse email" - -#: agenda_culturel/models.py:1712 -msgid "Your email address" -msgstr "Votre adresse email" - -#: agenda_culturel/models.py:1717 +#: agenda_culturel/models.py:1793 agenda_culturel/models.py:1841 msgid "Message" msgstr "Message" -#: agenda_culturel/models.py:1717 +#: agenda_culturel/models.py:1794 +msgid "Messages" +msgstr "Messages" + +#: agenda_culturel/models.py:1805 +msgid "Subject" +msgstr "Sujet" + +#: agenda_culturel/models.py:1806 +msgid "The subject of your message" +msgstr "Sujet de votre message" + +#: agenda_culturel/models.py:1812 +msgid "Related event" +msgstr "Événement associé" + +#: agenda_culturel/models.py:1813 +msgid "The message is associated with this event." +msgstr "Le message est associé à cet événement." + +#: agenda_culturel/models.py:1821 +msgid "Author of the message" +msgstr "Auteur du message" + +#: agenda_culturel/models.py:1829 +msgid "Your name" +msgstr "Votre nom" + +#: agenda_culturel/models.py:1835 +msgid "Email address" +msgstr "Adresse email" + +#: agenda_culturel/models.py:1836 +msgid "Your email address" +msgstr "Votre adresse email" + +#: agenda_culturel/models.py:1841 msgid "Your message" msgstr "Votre message" -#: agenda_culturel/models.py:1723 +#: agenda_culturel/models.py:1847 msgid "This message is a spam." msgstr "Ce message est un spam." -#: agenda_culturel/models.py:1730 +#: agenda_culturel/models.py:1854 msgid "this message has been processed and no longer needs to be handled" msgstr "Ce message a été traité et ne nécessite plus d'être pris en charge" -#: agenda_culturel/models.py:1735 +#: agenda_culturel/models.py:1859 msgid "Comments" msgstr "Commentaires" -#: agenda_culturel/models.py:1736 +#: agenda_culturel/models.py:1860 msgid "Comments on the message from the moderation team" msgstr "Commentaires sur ce message par l'équipe de modération" -#: agenda_culturel/models.py:1748 agenda_culturel/models.py:1896 +#: agenda_culturel/models.py:1875 agenda_culturel/models.py:2035 msgid "Recurrent import" msgstr "Import récurrent" -#: agenda_culturel/models.py:1749 +#: agenda_culturel/models.py:1876 msgid "Recurrent imports" msgstr "Imports récurrents" -#: agenda_culturel/models.py:1753 +#: agenda_culturel/models.py:1880 msgid "ical" msgstr "ical" -#: agenda_culturel/models.py:1754 +#: agenda_culturel/models.py:1881 msgid "ical no busy" msgstr "ical sans busy" -#: agenda_culturel/models.py:1755 +#: agenda_culturel/models.py:1882 msgid "ical no VC" msgstr "ical sans VC" -#: agenda_culturel/models.py:1756 +#: agenda_culturel/models.py:1883 msgid "lacoope.org" msgstr "lacoope.org" -#: agenda_culturel/models.py:1757 +#: agenda_culturel/models.py:1884 msgid "la comédie" msgstr "la comédie" -#: agenda_culturel/models.py:1758 +#: agenda_culturel/models.py:1885 msgid "le fotomat" msgstr "le fotomat" -#: agenda_culturel/models.py:1759 +#: agenda_culturel/models.py:1886 msgid "la puce à l'oreille" msgstr "la puce à loreille" -#: agenda_culturel/models.py:1760 +#: agenda_culturel/models.py:1887 msgid "Plugin wordpress MEC" msgstr "Plugin wordpress MEC" -#: agenda_culturel/models.py:1761 +#: agenda_culturel/models.py:1888 msgid "Événements d'une page FB" msgstr "Événements d'une page FB" -#: agenda_culturel/models.py:1762 +#: agenda_culturel/models.py:1889 msgid "la cour des 3 coquins" msgstr "la cour des 3 coquins" -#: agenda_culturel/models.py:1763 +#: agenda_culturel/models.py:1890 msgid "Arachnée concert" msgstr "Arachnée concert" -#: agenda_culturel/models.py:1766 +#: agenda_culturel/models.py:1891 +msgid "Le Rio" +msgstr "Le Rio" + +#: agenda_culturel/models.py:1894 msgid "simple" msgstr "simple" -#: agenda_culturel/models.py:1767 +#: agenda_culturel/models.py:1895 msgid "Headless Chromium" msgstr "chromium sans interface" -#: agenda_culturel/models.py:1768 +#: agenda_culturel/models.py:1896 msgid "Headless Chromium (pause)" msgstr "chromium sans interface (pause)" -#: agenda_culturel/models.py:1773 +#: agenda_culturel/models.py:1901 msgid "daily" msgstr "chaque jour" -#: agenda_culturel/models.py:1775 +#: agenda_culturel/models.py:1903 msgid "weekly" msgstr "chaque semaine" -#: agenda_culturel/models.py:1780 +#: agenda_culturel/models.py:1908 msgid "" "Recurrent import name. Be careful to choose a name that is easy to " "understand, as it will be public and displayed on the sites About page." @@ -771,143 +838,151 @@ msgstr "" "Nom de l'import récurrent. Attention à choisir un nom compréhensible, car il " "sera public, et affiché sur la page à propos du site." -#: agenda_culturel/models.py:1787 +#: agenda_culturel/models.py:1915 msgid "Processor" msgstr "Processeur" -#: agenda_culturel/models.py:1790 +#: agenda_culturel/models.py:1918 msgid "Downloader" msgstr "Téléchargeur" -#: agenda_culturel/models.py:1797 +#: agenda_culturel/models.py:1925 msgid "Import recurrence" msgstr "Récurrence d'import" -#: agenda_culturel/models.py:1804 +#: agenda_culturel/models.py:1932 msgid "Source" msgstr "Source" -#: agenda_culturel/models.py:1805 +#: agenda_culturel/models.py:1933 msgid "URL of the source document" msgstr "URL du document source" -#: agenda_culturel/models.py:1809 +#: agenda_culturel/models.py:1937 msgid "Browsable url" msgstr "URL navigable" -#: agenda_culturel/models.py:1811 +#: agenda_culturel/models.py:1939 msgid "URL of the corresponding document that will be shown to visitors." msgstr "URL correspondant au document et qui sera montrée aux visiteurs" -#: agenda_culturel/models.py:1820 +#: agenda_culturel/models.py:1948 msgid "Status of each imported event (published or draft)" msgstr "Status de chaque événement importé (publié ou brouillon)" -#: agenda_culturel/models.py:1825 +#: agenda_culturel/models.py:1953 msgid "Address for each imported event" msgstr "Adresse de chaque événement importé" -#: agenda_culturel/models.py:1833 +#: agenda_culturel/models.py:1960 +msgid "Force location" +msgstr "Focer la localisation" + +#: agenda_culturel/models.py:1961 +msgid "force location even if another is detected." +msgstr "Forcer la localisation même si une autre a été détectée." + +#: agenda_culturel/models.py:1967 msgid "Organiser" msgstr "Organisateur" -#: agenda_culturel/models.py:1834 +#: agenda_culturel/models.py:1968 msgid "Organiser of each imported event" msgstr "Organisateur de chaque événement importé" -#: agenda_culturel/models.py:1844 +#: agenda_culturel/models.py:1978 msgid "Category of each imported event" msgstr "Catégorie de chaque événement importé" -#: agenda_culturel/models.py:1852 +#: agenda_culturel/models.py:1986 msgid "Tags for each imported event" msgstr "Étiquettes de chaque événement importé" -#: agenda_culturel/models.py:1853 +#: agenda_culturel/models.py:1987 msgid "A list of tags that describe each imported event." msgstr "Une liste d'étiquettes décrivant chaque événement importé" -#: agenda_culturel/models.py:1882 +#: agenda_culturel/models.py:2016 msgid "Running" msgstr "En cours" -#: agenda_culturel/models.py:1883 +#: agenda_culturel/models.py:2017 msgid "Canceled" msgstr "Annulé" -#: agenda_culturel/models.py:1884 +#: agenda_culturel/models.py:2018 msgid "Success" msgstr "Succès" -#: agenda_culturel/models.py:1885 +#: agenda_culturel/models.py:2019 msgid "Failed" msgstr "Erreur" -#: agenda_culturel/models.py:1888 +#: agenda_culturel/models.py:2022 msgid "Batch importation" msgstr "Importation par lot" -#: agenda_culturel/models.py:1889 +#: agenda_culturel/models.py:2023 msgid "Batch importations" msgstr "Importations par lot" -#: agenda_culturel/models.py:1897 +#: agenda_culturel/models.py:2036 msgid "Reference to the recurrent import processing" msgstr "Référence du processus d'import récurrent" -#: agenda_culturel/models.py:1905 +#: agenda_culturel/models.py:2044 msgid "URL (if not recurrent import)" msgstr "URL (si pas d'import récurrent)" -#: agenda_culturel/models.py:1907 +#: agenda_culturel/models.py:2046 msgid "Source URL if no RecurrentImport is associated." msgstr "URL source si aucun import récurrent n'est associé" -#: agenda_culturel/models.py:1920 +#: agenda_culturel/models.py:2059 msgid "Error message" msgstr "Votre message" -#: agenda_culturel/models.py:1924 +#: agenda_culturel/models.py:2063 msgid "Number of collected events" msgstr "Nombre d'événements collectés" -#: agenda_culturel/models.py:1927 +#: agenda_culturel/models.py:2066 msgid "Number of imported events" msgstr "Nombre d'événements importés" -#: agenda_culturel/models.py:1930 +#: agenda_culturel/models.py:2069 msgid "Number of updated events" msgstr "Nombre d'événements mis à jour" -#: agenda_culturel/models.py:1933 +#: agenda_culturel/models.py:2072 msgid "Number of removed events" msgstr "Nombre d'événements supprimés" -#: agenda_culturel/models.py:1941 +#: agenda_culturel/models.py:2080 msgid "Weight" msgstr "Poids" -#: agenda_culturel/models.py:1942 +#: agenda_culturel/models.py:2081 msgid "The lower is the weight, the earlier the filter is applied" msgstr "Plus le poids est léger, plus le filtre sera appliqué tôt" -#: agenda_culturel/models.py:1949 +#: agenda_culturel/models.py:2088 msgid "Category applied to the event" msgstr "Catégorie appliquée à l'événement" -#: agenda_culturel/models.py:1954 +#: agenda_culturel/models.py:2093 msgid "Contained in the title" msgstr "Contenu dans le titre" -#: agenda_culturel/models.py:1955 +#: agenda_culturel/models.py:2094 msgid "Text contained in the event title" msgstr "Texte contenu dans le titre de l'événement" -#: agenda_culturel/models.py:1961 +#: agenda_culturel/models.py:2100 msgid "Exact title extract" msgstr "Extrait exact du titre" -#: agenda_culturel/models.py:1963 +#: agenda_culturel/models.py:2102 msgid "" "If checked, the extract will be searched for in the title using the exact " "form (capitals, accents)." @@ -915,19 +990,19 @@ msgstr "" "Si coché, l'extrait sera recherché dans le titre en utilisant la forme " "exacte (majuscules, accents)" -#: agenda_culturel/models.py:1969 +#: agenda_culturel/models.py:2108 msgid "Contained in the description" msgstr "Contenu dans la description" -#: agenda_culturel/models.py:1970 +#: agenda_culturel/models.py:2109 msgid "Text contained in the description" msgstr "Texte contenu dans la description" -#: agenda_culturel/models.py:1976 +#: agenda_culturel/models.py:2115 msgid "Exact description extract" msgstr "Extrait exact de description" -#: agenda_culturel/models.py:1978 +#: agenda_culturel/models.py:2117 msgid "" "If checked, the extract will be searched for in the description using the " "exact form (capitals, accents)." @@ -935,19 +1010,19 @@ msgstr "" "Si coché, l'extrait sera recherché dans la description en utilisant la forme " "exacte (majuscules, accents)" -#: agenda_culturel/models.py:1984 +#: agenda_culturel/models.py:2123 msgid "Contained in the location" msgstr "Contenu dans la localisation" -#: agenda_culturel/models.py:1985 +#: agenda_culturel/models.py:2124 msgid "Text contained in the event location" msgstr "Texte contenu dans la localisation de l'événement" -#: agenda_culturel/models.py:1991 +#: agenda_culturel/models.py:2130 msgid "Exact location extract" msgstr "Extrait exact de localisation" -#: agenda_culturel/models.py:1993 +#: agenda_culturel/models.py:2132 msgid "" "If checked, the extract will be searched for in the location using the exact " "form (capitals, accents)." @@ -955,43 +1030,43 @@ msgstr "" "Si coché, l'extrait sera recherché dans la localisation en utilisant la " "forme exacte (majuscules, accents)" -#: agenda_culturel/models.py:2001 +#: agenda_culturel/models.py:2140 msgid "Location from place" msgstr "Localisation depuis le lieu" -#: agenda_culturel/models.py:2010 +#: agenda_culturel/models.py:2149 msgid "Categorisation rule" msgstr "Règle de catégorisation" -#: agenda_culturel/models.py:2011 +#: agenda_culturel/models.py:2150 msgid "Categorisation rules" msgstr "Règles de catégorisation" -#: agenda_culturel/settings/base.py:151 +#: agenda_culturel/settings/base.py:153 msgid "French" msgstr "français" -#: agenda_culturel/views.py:147 +#: agenda_culturel/views.py:154 msgid "Recurrent import name" msgstr "Nome de l'import récurrent" -#: agenda_culturel/views.py:148 +#: agenda_culturel/views.py:155 msgid "Add another" msgstr "Ajouter un autre" -#: agenda_culturel/views.py:149 +#: agenda_culturel/views.py:156 msgid "Browse..." msgstr "Naviguer..." -#: agenda_culturel/views.py:150 +#: agenda_culturel/views.py:157 msgid "No file selected." msgstr "Pas de fichier sélectionné." -#: agenda_culturel/views.py:286 +#: agenda_culturel/views.py:293 msgid "The static content has been successfully updated." msgstr "Le contenu statique a été modifié avec succès." -#: agenda_culturel/views.py:294 +#: agenda_culturel/views.py:301 msgid "" "The event cannot be updated because the import process is not available for " "the referenced sources." @@ -999,33 +1074,37 @@ msgstr "" "La mise à jour de l'événement n'est pas possible car le processus d'import " "n'est pas disponible pour les sources référencées." -#: agenda_culturel/views.py:297 +#: agenda_culturel/views.py:304 msgid "The event update has been queued and will be completed shortly." msgstr "" "La mise à jour de l'événement a été mise en attente et sera effectuée sous " "peu." -#: agenda_culturel/views.py:307 +#: agenda_culturel/views.py:314 msgid "The event has been successfully modified." msgstr "L'événement a été modifié avec succès." -#: agenda_culturel/views.py:352 -msgid "The event has been successfully moderated." -msgstr "L'événement a été modéré avec succès." +#: agenda_culturel/views.py:365 +msgid "The event {} has been moderated with success." +msgstr "L'événement {} a été modéré avec succès." -#: agenda_culturel/views.py:446 +#: agenda_culturel/views.py:480 msgid "The event has been successfully deleted." msgstr "L'événement a été supprimé avec succès." -#: agenda_culturel/views.py:478 +#: agenda_culturel/views.py:522 +msgid "Comment" +msgstr "Commentaire" + +#: agenda_culturel/views.py:541 msgid "The status has been successfully modified." msgstr "Le status a été modifié avec succès." -#: agenda_culturel/views.py:512 +#: agenda_culturel/views.py:575 msgid "The event was created: {}." msgstr "L'événement a été créé: {}." -#: agenda_culturel/views.py:514 agenda_culturel/views.py:538 +#: agenda_culturel/views.py:577 msgid "" "The event has been submitted and will be published as soon as it has been " "validated by the moderation team." @@ -1033,86 +1112,82 @@ msgstr "" "L'événement a été soumis et sera publié dès qu'il aura été validé par " "l'équipe de modération." -#: agenda_culturel/views.py:532 -msgid "The event is saved." -msgstr "L'événement est enregistré." - -#: agenda_culturel/views.py:626 agenda_culturel/views.py:678 +#: agenda_culturel/views.py:671 agenda_culturel/views.py:723 msgid "{} has not been submitted since its already known: {}." msgstr "{} n'a pas été soumis car il est déjà connu: {}." -#: agenda_culturel/views.py:631 agenda_culturel/views.py:684 +#: agenda_culturel/views.py:676 agenda_culturel/views.py:729 msgid "" "{} has not been submitted since its already known and currently into " "moderation process." msgstr "{} n'a pas été soumis car il est déjà connu et en cours de modération" -#: agenda_culturel/views.py:641 +#: agenda_culturel/views.py:686 msgid "Integrating {} url(s) into our import process." msgstr "Intégration de {} url(s) dans notre processus d'import." -#: agenda_culturel/views.py:691 +#: agenda_culturel/views.py:736 msgid "Integrating {} into our import process." msgstr "Intégration de {} dans notre processus d'import." -#: agenda_culturel/views.py:746 +#: agenda_culturel/views.py:794 msgid "Your message has been sent successfully." msgstr "Votre message a été envoyé avec succès." -#: agenda_culturel/views.py:763 +#: agenda_culturel/views.py:822 msgid "Reporting the event {} on {}" msgstr "Signaler l'événement {} du {}" -#: agenda_culturel/views.py:771 +#: agenda_culturel/views.py:832 msgid "The contact message has been successfully deleted." msgstr "Le message de contact a été supprimé avec succès." -#: agenda_culturel/views.py:785 +#: agenda_culturel/views.py:846 msgid "The contact message properties has been successfully modified." msgstr "Les propriétés du message de contact ont été modifié avec succès." -#: agenda_culturel/views.py:922 +#: agenda_culturel/views.py:1015 msgid "Spam has been successfully deleted." msgstr "Le spam a été supprimé avec succès" -#: agenda_culturel/views.py:1045 +#: agenda_culturel/views.py:1138 msgid "The import has been run successfully." msgstr "L'import a été lancé avec succès" -#: agenda_culturel/views.py:1064 +#: agenda_culturel/views.py:1157 msgid "The import has been canceled." msgstr "L'import a été annulé" -#: agenda_culturel/views.py:1142 +#: agenda_culturel/views.py:1235 msgid "The recurrent import has been successfully modified." msgstr "L'import récurrent a été modifié avec succès." -#: agenda_culturel/views.py:1151 +#: agenda_culturel/views.py:1244 msgid "The recurrent import has been successfully deleted." msgstr "L'import récurrent a été supprimé avec succès" -#: agenda_culturel/views.py:1191 +#: agenda_culturel/views.py:1284 msgid "The import has been launched." msgstr "L'import a été lancé" -#: agenda_culturel/views.py:1213 +#: agenda_culturel/views.py:1306 msgid "Imports has been launched." msgstr "Les imports ont été lancés" -#: agenda_culturel/views.py:1275 +#: agenda_culturel/views.py:1368 msgid "Update successfully completed." msgstr "Mise à jour réalisée avec succès." -#: agenda_culturel/views.py:1336 +#: agenda_culturel/views.py:1429 msgid "Creation of a merged event has been successfully completed." msgstr "Création d'un événement fusionné réalisée avec succès." -#: agenda_culturel/views.py:1372 +#: agenda_culturel/views.py:1465 msgid "Events have been marked as unduplicated." msgstr "Les événements ont été marqués comme non dupliqués." -#: agenda_culturel/views.py:1386 agenda_culturel/views.py:1395 -#: agenda_culturel/views.py:1413 +#: agenda_culturel/views.py:1479 agenda_culturel/views.py:1488 +#: agenda_culturel/views.py:1506 msgid "" "The selected item is no longer included in the list of duplicates. Someone " "else has probably modified the list in the meantime." @@ -1120,23 +1195,23 @@ msgstr "" "L'élément sélectionné ne fait plus partie de la liste des dupliqués. Une " "autre personne a probablement modifié la liste entre temps." -#: agenda_culturel/views.py:1389 +#: agenda_culturel/views.py:1482 msgid "The selected event has been set as representative" msgstr "L'événement sélectionné a été défini comme representatif." -#: agenda_culturel/views.py:1404 +#: agenda_culturel/views.py:1497 msgid "The event has been withdrawn from the group and made independent." msgstr "L'événement a été retiré du groupe et rendu indépendant." -#: agenda_culturel/views.py:1448 +#: agenda_culturel/views.py:1541 msgid "Cleaning up duplicates: {} item(s) fixed." msgstr "Nettoyage des dupliqués: {} élément(s) corrigé(s)." -#: agenda_culturel/views.py:1497 +#: agenda_culturel/views.py:1590 msgid "The event was successfully duplicated." msgstr "L'événement a été marqué dupliqué avec succès." -#: agenda_culturel/views.py:1505 +#: agenda_culturel/views.py:1598 msgid "" "The event has been successfully flagged as a duplicate. The moderation team " "will deal with your suggestion shortly." @@ -1144,32 +1219,32 @@ msgstr "" "L'événement a été signalé comme dupliqué avec succès. Votre suggestion sera " "prochainement prise en charge par l'équipe de modération." -#: agenda_culturel/views.py:1558 +#: agenda_culturel/views.py:1651 msgid "The categorisation rule has been successfully modified." msgstr "La règle de catégorisation a été modifiée avec succès." -#: agenda_culturel/views.py:1567 +#: agenda_culturel/views.py:1660 msgid "The categorisation rule has been successfully deleted." msgstr "La règle de catégorisation a été supprimée avec succès" -#: agenda_culturel/views.py:1589 +#: agenda_culturel/views.py:1682 msgid "The rules were successfully applied and 1 event was categorised." msgstr "" "Les règles ont été appliquées avec succès et 1 événement a été catégorisé" -#: agenda_culturel/views.py:1596 +#: agenda_culturel/views.py:1689 msgid "The rules were successfully applied and {} events were categorised." msgstr "" "Les règles ont été appliquées avec succès et {} événements ont été " "catégorisés" -#: agenda_culturel/views.py:1603 agenda_culturel/views.py:1656 +#: agenda_culturel/views.py:1696 agenda_culturel/views.py:1749 msgid "The rules were successfully applied and no events were categorised." msgstr "" "Les règles ont été appliquées avec succès et aucun événement n'a été " "catégorisé" -#: agenda_culturel/views.py:1642 +#: agenda_culturel/views.py:1735 msgid "" "The rules were successfully applied and 1 event with default category was " "categorised." @@ -1177,7 +1252,7 @@ msgstr "" "Les règles ont été appliquées avec succès et 1 événement avec catégorie par " "défaut a été catégorisé" -#: agenda_culturel/views.py:1649 +#: agenda_culturel/views.py:1742 msgid "" "The rules were successfully applied and {} events with default category were " "categorised." @@ -1185,58 +1260,58 @@ msgstr "" "Les règles ont été appliquées avec succès et {} événements avec catégorie " "par défaut ont été catégorisés" -#: agenda_culturel/views.py:1741 agenda_culturel/views.py:1803 -#: agenda_culturel/views.py:1841 +#: agenda_culturel/views.py:1834 agenda_culturel/views.py:1896 +#: agenda_culturel/views.py:1934 msgid "{} events have been updated." msgstr "{} événements ont été mis à jour." -#: agenda_culturel/views.py:1744 agenda_culturel/views.py:1805 -#: agenda_culturel/views.py:1844 +#: agenda_culturel/views.py:1837 agenda_culturel/views.py:1898 +#: agenda_culturel/views.py:1937 msgid "1 event has been updated." msgstr "1 événement a été mis à jour" -#: agenda_culturel/views.py:1746 agenda_culturel/views.py:1807 -#: agenda_culturel/views.py:1846 +#: agenda_culturel/views.py:1839 agenda_culturel/views.py:1900 +#: agenda_culturel/views.py:1939 msgid "No events have been modified." msgstr "Aucun événement n'a été modifié." -#: agenda_culturel/views.py:1755 +#: agenda_culturel/views.py:1848 msgid "The place has been successfully updated." msgstr "Le lieu a été modifié avec succès." -#: agenda_culturel/views.py:1764 +#: agenda_culturel/views.py:1857 msgid "The place has been successfully created." msgstr "Le lieu a été créé avec succès." -#: agenda_culturel/views.py:1829 +#: agenda_culturel/views.py:1922 msgid "The selected place has been assigned to the event." msgstr "Le lieu sélectionné a été assigné à l'événement." -#: agenda_culturel/views.py:1833 +#: agenda_culturel/views.py:1926 msgid "A new alias has been added to the selected place." msgstr "Un nouvel alias a été créé pour le lieu sélectionné." -#: agenda_culturel/views.py:1926 +#: agenda_culturel/views.py:2028 msgid "The organisation has been successfully updated." msgstr "L'organisme a été modifié avec succès." -#: agenda_culturel/views.py:1935 +#: agenda_culturel/views.py:2037 msgid "The organisation has been successfully created." msgstr "L'organisme a été créé avec succès." -#: agenda_culturel/views.py:1952 +#: agenda_culturel/views.py:2054 msgid "The tag has been successfully updated." msgstr "L'étiquette a été modifiée avec succès." -#: agenda_culturel/views.py:1959 +#: agenda_culturel/views.py:2061 msgid "The tag has been successfully created." msgstr "L'étiquette a été créée avec succès." -#: agenda_culturel/views.py:2023 +#: agenda_culturel/views.py:2125 msgid "You have not modified the tag name." msgstr "Vous n'avez pas modifié le nom de l'étiquette." -#: agenda_culturel/views.py:2033 +#: agenda_culturel/views.py:2135 msgid "" "This tag {} is already in use, and is described by different information " "from the current tag. You can force renaming by checking the corresponding " @@ -1249,7 +1324,7 @@ msgstr "" "sera supprimée, et tous les événements associés à l'étiquette {} seront " "associés à l'étiquette {}." -#: agenda_culturel/views.py:2040 +#: agenda_culturel/views.py:2142 msgid "" "This tag {} is already in use. You can force renaming by checking the " "corresponding option." @@ -1257,10 +1332,14 @@ msgstr "" "Cette étiquette {} est déjà utilisée. Vous pouvez forcer le renommage en " "cochant l'option correspondante." -#: agenda_culturel/views.py:2074 +#: agenda_culturel/views.py:2176 msgid "The tag {} has been successfully renamed to {}." msgstr "L'étiquette {} a été renommée avec succès en {}." -#: agenda_culturel/views.py:2112 +#: agenda_culturel/views.py:2214 msgid "The tag {} has been successfully deleted." msgstr "L'événement {} a été supprimé avec succès." + +#: agenda_culturel/views.py:2235 +msgid "Cache successfully cleared." +msgstr "Le cache a été vidé avec succès." diff --git a/src/agenda_culturel/migrations/0081_auto_20241010_2235.py b/src/agenda_culturel/migrations/0081_auto_20241010_2235.py index 1f6e475..fcc4fd3 100644 --- a/src/agenda_culturel/migrations/0081_auto_20241010_2235.py +++ b/src/agenda_culturel/migrations/0081_auto_20241010_2235.py @@ -1,11 +1,11 @@ # Generated by Django 4.2.9 on 2024-10-10 20:35 from django.db import migrations -from agenda_culturel.models import Place from django.contrib.gis.geos import Point def change_coord_format(apps, schema_editor): - places = Place.objects.all() + Place = apps.get_model("agenda_culturel", "Place") + places = Place.objects.values("location", "location_pt").all() for p in places: l = p.location.split(',') @@ -13,14 +13,15 @@ def change_coord_format(apps, schema_editor): p.location_pt = Point(float(l[1]), float(l[0])) else: p.location_pt = Point(3.08333, 45.783329) - p.save() + p.save(update_fields=["location_pt"]) def reverse_coord_format(apps, schema_editor): - places = Place.objects.all() + Place = apps.get_model("agenda_culturel", "Place") + places = Place.objects.values("location", "location_pt").all() for p in places: p.location = ','.join([p.location_pt[1], p.location_pt[0]]) - p.save() + p.save(update_fields=["location"]) diff --git a/src/agenda_culturel/migrations/0122_alter_recurrentimport_processor.py b/src/agenda_culturel/migrations/0122_alter_recurrentimport_processor.py new file mode 100644 index 0000000..0758649 --- /dev/null +++ b/src/agenda_culturel/migrations/0122_alter_recurrentimport_processor.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.9 on 2024-11-29 13:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('agenda_culturel', '0121_contactmessage_related_event'), + ] + + operations = [ + migrations.AlterField( + model_name='recurrentimport', + name='processor', + field=models.CharField(choices=[('ical', 'ical'), ('icalnobusy', 'ical no busy'), ('icalnovc', 'ical no VC'), ('lacoope', 'lacoope.org'), ('lacomedie', 'la comédie'), ('lefotomat', 'le fotomat'), ('lapucealoreille', "la puce à l'oreille"), ('Plugin wordpress MEC', 'Plugin wordpress MEC'), ('Facebook events', "Événements d'une page FB"), ('cour3coquins', 'la cour des 3 coquins'), ('arachnee', 'Arachnée concert'), ('rio', 'Le Rio')], default='ical', max_length=20, verbose_name='Processor'), + ), + ] diff --git a/src/agenda_culturel/migrations/0123_event_created_by_user_event_imported_by_user_and_more.py b/src/agenda_culturel/migrations/0123_event_created_by_user_event_imported_by_user_and_more.py new file mode 100644 index 0000000..a8359ce --- /dev/null +++ b/src/agenda_culturel/migrations/0123_event_created_by_user_event_imported_by_user_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.9 on 2024-11-29 18:18 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('agenda_culturel', '0122_alter_recurrentimport_processor'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='created_by_user', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='created_events', to=settings.AUTH_USER_MODEL, verbose_name='Author of the event creation'), + ), + migrations.AddField( + model_name='event', + name='imported_by_user', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='imported_events', to=settings.AUTH_USER_MODEL, verbose_name='Author of the last importation'), + ), + migrations.AddField( + model_name='event', + name='moderated_by_user', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='moderated_events', to=settings.AUTH_USER_MODEL, verbose_name='Author of the last moderation'), + ), + migrations.AddField( + model_name='event', + name='modified_by_user', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='modified_events', to=settings.AUTH_USER_MODEL, verbose_name='Author of the last modification'), + ), + ] diff --git a/src/agenda_culturel/migrations/0124_place_postcode.py b/src/agenda_culturel/migrations/0124_place_postcode.py new file mode 100644 index 0000000..9581868 --- /dev/null +++ b/src/agenda_culturel/migrations/0124_place_postcode.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.9 on 2024-12-06 21:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('agenda_culturel', '0123_event_created_by_user_event_imported_by_user_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='place', + name='postcode', + field=models.CharField(blank=True, help_text='The post code is not displayed, but makes it easier to find an address when you enter it.', null=True, verbose_name='Postcode'), + ), + ] diff --git a/src/agenda_culturel/migrations/0125_rename_contactmessage_message_alter_message_options.py b/src/agenda_culturel/migrations/0125_rename_contactmessage_message_alter_message_options.py new file mode 100644 index 0000000..958fb15 --- /dev/null +++ b/src/agenda_culturel/migrations/0125_rename_contactmessage_message_alter_message_options.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.9 on 2024-12-11 11:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('agenda_culturel', '0124_place_postcode'), + ] + + operations = [ + migrations.RenameModel( + old_name='ContactMessage', + new_name='Message', + ), + migrations.AlterModelOptions( + name='message', + options={'verbose_name': 'Message', 'verbose_name_plural': 'Messages'}, + ), + ] diff --git a/src/agenda_culturel/migrations/0126_message_user.py b/src/agenda_culturel/migrations/0126_message_user.py new file mode 100644 index 0000000..42caa87 --- /dev/null +++ b/src/agenda_culturel/migrations/0126_message_user.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.9 on 2024-12-11 11:56 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('agenda_culturel', '0125_rename_contactmessage_message_alter_message_options'), + ] + + operations = [ + migrations.AddField( + model_name='message', + name='user', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to=settings.AUTH_USER_MODEL, verbose_name='Author of the message'), + ), + ] diff --git a/src/agenda_culturel/migrations/0127_event_agenda_cult_end_day_4660a5_idx_and_more.py b/src/agenda_culturel/migrations/0127_event_agenda_cult_end_day_4660a5_idx_and_more.py new file mode 100644 index 0000000..d241af1 --- /dev/null +++ b/src/agenda_culturel/migrations/0127_event_agenda_cult_end_day_4660a5_idx_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.9 on 2024-12-11 19:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('agenda_culturel', '0126_message_user'), + ] + + operations = [ + migrations.AddIndex( + model_name='event', + index=models.Index(fields=['end_day', 'end_time'], name='agenda_cult_end_day_4660a5_idx'), + ), + migrations.AddIndex( + model_name='event', + index=models.Index(fields=['status'], name='agenda_cult_status_893243_idx'), + ), + migrations.AddIndex( + model_name='event', + index=models.Index(fields=['recurrence_dtstart', 'recurrence_dtend'], name='agenda_cult_recurre_a8911c_idx'), + ), + ] diff --git a/src/agenda_culturel/migrations/0128_event_datetimes_title.py b/src/agenda_culturel/migrations/0128_event_datetimes_title.py new file mode 100644 index 0000000..2b0ca51 --- /dev/null +++ b/src/agenda_culturel/migrations/0128_event_datetimes_title.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.9 on 2024-12-11 19:12 + +from django.db import migrations, models +import django.db.models.functions.text + + +class Migration(migrations.Migration): + + dependencies = [ + ('agenda_culturel', '0127_event_agenda_cult_end_day_4660a5_idx_and_more'), + ] + + operations = [ + migrations.AddIndex( + model_name='event', + index=models.Index(models.F('start_time'), models.F('start_day'), models.F('end_day'), models.F('end_time'), django.db.models.functions.text.Lower('title'), name='datetimes title'), + ), + ] diff --git a/src/agenda_culturel/migrations/0129_batchimportation_agenda_cult_created_a23990_idx_and_more.py b/src/agenda_culturel/migrations/0129_batchimportation_agenda_cult_created_a23990_idx_and_more.py new file mode 100644 index 0000000..d88f1c2 --- /dev/null +++ b/src/agenda_culturel/migrations/0129_batchimportation_agenda_cult_created_a23990_idx_and_more.py @@ -0,0 +1,57 @@ +# Generated by Django 4.2.9 on 2024-12-11 19:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('agenda_culturel', '0128_event_datetimes_title'), + ] + + operations = [ + migrations.AddIndex( + model_name='batchimportation', + index=models.Index(fields=['created_date'], name='agenda_cult_created_a23990_idx'), + ), + migrations.AddIndex( + model_name='batchimportation', + index=models.Index(fields=['status'], name='agenda_cult_status_54b205_idx'), + ), + migrations.AddIndex( + model_name='batchimportation', + index=models.Index(fields=['created_date', 'recurrentImport'], name='agenda_cult_created_0296e4_idx'), + ), + migrations.AddIndex( + model_name='duplicatedevents', + index=models.Index(fields=['representative'], name='agenda_cult_represe_9a4fa2_idx'), + ), + migrations.AddIndex( + model_name='message', + index=models.Index(fields=['related_event'], name='agenda_cult_related_79de3c_idx'), + ), + migrations.AddIndex( + model_name='message', + index=models.Index(fields=['user'], name='agenda_cult_user_id_42dc88_idx'), + ), + migrations.AddIndex( + model_name='message', + index=models.Index(fields=['date'], name='agenda_cult_date_049c71_idx'), + ), + migrations.AddIndex( + model_name='message', + index=models.Index(fields=['spam', 'closed'], name='agenda_cult_spam_22f9b3_idx'), + ), + migrations.AddIndex( + model_name='place', + index=models.Index(fields=['name'], name='agenda_cult_name_222846_idx'), + ), + migrations.AddIndex( + model_name='place', + index=models.Index(fields=['city'], name='agenda_cult_city_156dc7_idx'), + ), + migrations.AddIndex( + model_name='place', + index=models.Index(fields=['location'], name='agenda_cult_locatio_6f3c05_idx'), + ), + ] diff --git a/src/agenda_culturel/migrations/0130_recurrentimport_forcelocation.py b/src/agenda_culturel/migrations/0130_recurrentimport_forcelocation.py new file mode 100644 index 0000000..9befee0 --- /dev/null +++ b/src/agenda_culturel/migrations/0130_recurrentimport_forcelocation.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.9 on 2024-12-22 15:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('agenda_culturel', '0129_batchimportation_agenda_cult_created_a23990_idx_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='recurrentimport', + name='forceLocation', + field=models.BooleanField(default=False, help_text='force location even if another is detected.', verbose_name='Force location'), + ), + ] diff --git a/src/agenda_culturel/models.py b/src/agenda_culturel/models.py index d7b3909..c6071c5 100644 --- a/src/agenda_culturel/models.py +++ b/src/agenda_culturel/models.py @@ -10,7 +10,11 @@ from colorfield.fields import ColorField from django_ckeditor_5.fields import CKEditor5Field from urllib.parse import urlparse from django.core.cache import cache +from django.core.cache.utils import make_template_fragment_key +from django.contrib.auth.models import User, AnonymousUser import emoji +from django.core.files.storage import default_storage +import uuid import hashlib import urllib.request @@ -20,6 +24,7 @@ from django.utils import timezone from django.contrib.postgres.search import TrigramSimilarity from django.db.models import Q, Count, F, Subquery, OuterRef, Func from django.db.models.functions import Lower +from django.contrib.postgres.lookups import Unaccent import recurrence.fields import recurrence import copy @@ -284,6 +289,10 @@ class DuplicatedEvents(models.Model): class Meta: verbose_name = _("Duplicated events") verbose_name_plural = _("Duplicated events") + indexes = [ + models.Index(fields=['representative']), + ] + def __init__(self, *args, **kwargs): self.events = None @@ -434,8 +443,9 @@ class Place(models.Model): blank=True, null=True, ) + postcode = models.CharField(verbose_name=_("Postcode"), help_text=_("The post code is not displayed, but makes it easier to find an address when you enter it."), blank=True, null=True) city = models.CharField(verbose_name=_("City"), help_text=_("City name")) - location = LocationField(based_fields=["name", "address", "city"], zoom=12, default=Point(3.08333, 45.783329)) + location = LocationField(based_fields=["name", "address", "postcode", "city"], zoom=12, default=Point(3.08333, 45.783329)) description = CKEditor5Field( verbose_name=_("Description"), @@ -458,6 +468,11 @@ class Place(models.Model): verbose_name = _("Place") verbose_name_plural = _("Places") ordering = ["name"] + indexes = [ + models.Index(fields=['name']), + models.Index(fields=['city']), + models.Index(fields=['location']), + ] def __str__(self): if self.address: @@ -543,7 +558,7 @@ class Organisation(models.Model): return self.name def get_absolute_url(self): - return reverse("view_organisation", kwargs={'pk': self.pk}) + return reverse("view_organisation", kwargs={'pk': self.pk, "extra": self.name}) @@ -558,6 +573,39 @@ class Event(models.Model): modified_date = models.DateTimeField(blank=True, null=True) moderated_date = models.DateTimeField(blank=True, null=True) + created_by_user = models.ForeignKey( + User, + verbose_name=_("Author of the event creation"), + null=True, + default=None, + on_delete=models.SET_DEFAULT, + related_name="created_events" + ) + imported_by_user = models.ForeignKey( + User, + verbose_name=_("Author of the last importation"), + null=True, + default=None, + on_delete=models.SET_DEFAULT, + related_name="imported_events" + ) + modified_by_user = models.ForeignKey( + User, + verbose_name=_("Author of the last modification"), + null=True, + default=None, + on_delete=models.SET_DEFAULT, + related_name="modified_events" + ) + moderated_by_user = models.ForeignKey( + User, + verbose_name=_("Author of the last moderation"), + null=True, + default=None, + on_delete=models.SET_DEFAULT, + related_name="moderated_events" + ) + recurrence_dtstart = models.DateTimeField(editable=False, blank=True, null=True) recurrence_dtend = models.DateTimeField(editable=False, blank=True, null=True) @@ -692,6 +740,10 @@ class Event(models.Model): blank=True, ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.processing_user = None + def get_consolidated_end_day(self, intuitive=True): if intuitive: end_day = self.get_consolidated_end_day(False) @@ -709,15 +761,6 @@ class Event(models.Model): last = self.get_consolidated_end_day() return [first + timedelta(n) for n in range(int((last - first).days) + 1)] - def get_nb_events_same_dates(self, remove_same_dup=True): - first = self.start_day - last = self.get_consolidated_end_day() - ignore_dup = None - if remove_same_dup: - ignore_dup = self.other_versions - calendar = CalendarList(first, last, exact=True, ignore_dup=ignore_dup) - return [(len(d.events), d.date) for dstr, d in calendar.get_calendar_days().items()] - def is_single_day(self, intuitive=True): return self.start_day == self.get_consolidated_end_day(intuitive) @@ -727,6 +770,15 @@ class Event(models.Model): end_date = parse_date(end_date) return parse_date(self.start_day) + timedelta(days=min_days) < end_date + def set_message(self, msg): + self._message = msg + + def get_message(self): + return self._message + + def has_message(self): + return hasattr(self, '_message') + def contains_date(self, d, intuitive=True): return d >= self.start_day and d <= self.get_consolidated_end_day(intuitive) @@ -764,9 +816,36 @@ class Event(models.Model): permissions = [("set_duplicated_event", "Can set an event as duplicated")] indexes = [ models.Index(fields=["start_day", "start_time"]), - models.Index("start_time", Lower("title"), name="start_time title") + models.Index(fields=["end_day", "end_time"]), + models.Index(fields=["status"]), + models.Index(fields=["recurrence_dtstart", "recurrence_dtend"]), + models.Index("start_time", Lower("title"), name="start_time title"), + models.Index("start_time", "start_day", "end_day", "end_time", Lower("title"), name="datetimes title") ] + def chronology(self): + c = [] + if self.modified_date: + c.append({ "timestamp": self.modified_date, "data": "modified_date", "user": self.modified_by_user, "is_date": True }) + if self.moderated_date: + c.append({ "timestamp": self.moderated_date, "data": "moderated_date", "user" : self.moderated_by_user, "is_date": True}) + if self.imported_date: + c.append({ "timestamp": self.imported_date, "data": "imported_date", "user": self.imported_by_user, "is_date": True }) + if self.created_date: + c.append({ "timestamp": self.created_date + timedelta(milliseconds=-1), "data": "created_date", "user": self.created_by_user, "is_date": True}) + + c += [{ "timestamp": m.date, "data": m, "user": m.user, "is_date": False} for m in self.message_set.filter(spam=False)] + + if self.other_versions: + for o in self.other_versions.get_duplicated(): + if o != self: + c += [{ "timestamp": m.date, "data": m, "user": m.user, "is_date": False} for m in o.message_set.filter(spam=False)] + + + c.sort(key=lambda x: x["timestamp"]) + + return c + def sorted_tags(self): if self.tags is None: return [] @@ -858,19 +937,28 @@ class Event(models.Model): def is_representative(self): return self.other_versions is None or self.other_versions.representative == self + def download_missing_image(self): + if self.local_image and not default_storage.exists(self.local_image.name): + logger.warning("on dl") + self.download_image() + self.save(update_fields=["local_image"]) + def download_image(self): # first download file a = urlparse(self.image) basename = os.path.basename(a.path) + ext = basename.split('.')[-1] + filename = "%s.%s" % (uuid.uuid4(), ext) + try: tmpfile, _ = urllib.request.urlretrieve(self.image) except: return None # if the download is ok, then create the corresponding file object - self.local_image = File(name=basename, file=open(tmpfile, "rb")) + self.local_image = File(name=filename, file=open(tmpfile, "rb")) def add_pending_organisers(self, organisers): self.pending_organisers = organisers @@ -896,6 +984,12 @@ class Event(models.Model): def set_no_modification_date_changed(self): self.no_modification_date_changed = True + def set_processing_user(self, user): + if user is None or user.is_anonymous: + self.processing_user = None + else: + self.processing_user = user + def set_in_moderation_process(self): self.in_moderation_process = True @@ -906,12 +1000,16 @@ class Event(models.Model): now = timezone.now() if not self.id: self.created_date = now + self.created_by_user = self.processing_user if self.is_in_importation_process(): self.imported_date = now + self.imported_by_user = self.processing_user if self.modified_date is None or not self.is_no_modification_date_changed(): self.modified_date = now + self.modified_by_user = self.processing_user if self.is_in_moderation_process(): self.moderated_date = now + self.moderated_by_user = self.processing_user def get_recurrence_at_date(self, year, month, day): dtstart = timezone.make_aware( @@ -923,10 +1021,13 @@ class Event(models.Model): else: return recurrences[0] - def get_image_url(self): + def get_image_url(self, request=None): if self.local_image and hasattr(self.local_image, "url"): try: - return self.local_image.url + if request: + return request.build_absolute_uri(self.local_image.url) + else: + return self.local_image.url except: pass if self.image: @@ -1024,7 +1125,7 @@ class Event(models.Model): self.update_recurrence_dtstartend() # if the image is defined but not locally downloaded - if self.image and not self.local_image: + if self.image and (not self.local_image or not default_storage.exists(self.local_image.name)): self.download_image() # remove "/" from tags @@ -1076,6 +1177,11 @@ class Event(models.Model): # first save the current object super().save(*args, **kwargs) + # clear cache + for is_auth in [False, True]: + key = make_template_fragment_key("event_body", [is_auth, self]) + cache.delete(key) + # then if its a clone, update the representative if clone: self.other_versions.representative = self @@ -1322,9 +1428,10 @@ class Event(models.Model): # otherwise merge existing groups group = DuplicatedEvents.merge_groups(groups) + group.save() + if force_non_fixed: group.representative = None - group.save() # set the possibly duplicated group for the current object self.other_versions = group @@ -1345,6 +1452,8 @@ class Event(models.Model): "category", "tags", ] + if not no_m2m: + result += ["organisers"] result += [ "title", @@ -1356,8 +1465,6 @@ class Event(models.Model): "description", "image", ] - if not no_m2m: - result += ["organisers"] if all and local_img: result += ["local_image"] if all and exact_location: @@ -1409,7 +1516,11 @@ class Event(models.Model): self.import_sources.append(source) # Limitation: the given events should not be considered similar one to another... - def import_events(events, remove_missing_from_source=None): + def import_events(events, remove_missing_from_source=None, user_id=None): + + user = None + if user_id: + user = User.objects.filter(pk=user_id).first() to_import = [] to_update = [] @@ -1436,6 +1547,7 @@ class Event(models.Model): # imported events should be updated event.set_in_importation_process() + event.set_processing_user(user) event.prepare_save() # check if the event has already be imported (using uuid) @@ -1462,9 +1574,14 @@ class Event(models.Model): same_imported.other_versions.representative = None same_imported.other_versions.save() # we only update local information if it's a pure import and has no moderated_date + new_image = same_imported.image != event.image same_imported.update(event, pure and same_imported.moderated_date is None) same_imported.set_in_importation_process() same_imported.prepare_save() + # fix missing or updated files + if same_imported.local_image and (not default_storage.exists(same_imported.local_image.name) or new_image): + same_imported.download_image() + same_imported.save(update_fields=["local_image"]) to_update.append(same_imported) else: # otherwise, the new event possibly a duplication of the remaining others. @@ -1492,6 +1609,7 @@ class Event(models.Model): for e in to_import: if e.is_event_long_duration(): e.status = Event.STATUS.DRAFT + e.set_message(_("The duration of the event is a little too long for direct publication. Moderators can choose to publish it or not.")) # then import all the new events imported = Event.objects.bulk_create(to_import) @@ -1499,6 +1617,9 @@ class Event(models.Model): for i, ti in zip(imported, to_import): if ti.has_pending_organisers() and ti.pending_organisers is not None: i.organisers.set(ti.pending_organisers) + if ti.has_message(): + msg = Message(subject=_('Import'), related_event=i, name=_('import process'), message=ti.get_message()) + msg.save() nb_updated = Event.objects.bulk_update( to_update, @@ -1572,13 +1693,12 @@ class Event(models.Model): def get_concurrent_events(self, remove_same_dup=True): day = self.current_date if hasattr(self, "current_date") else self.start_day - day_events = CalendarDay(self.start_day).get_events() + day_events = CalendarDay(day, qs = Event.objects.filter(status=Event.STATUS.PUBLISHED)).get_events() return [ e for e in day_events if e != self and self.is_concurrent_event(e, day) - and e.status == Event.STATUS.PUBLISHED and (e.other_versions is None or e.other_versions != self.other_versions) ] @@ -1588,10 +1708,10 @@ class Event(models.Model): return (dtstart <= e_dtstart <= dtend) or (e_dtstart <= dtstart <= e_dtend) - def export_to_ics(events): + def export_to_ics(events, request): cal = icalCal() # Some properties are required to be compliant - cal.add("prodid", "-//My calendar product//example.com//") + cal.add("prodid", "-//Pommes de lune//pommesdelune.fr//") cal.add("version", "2.0") for event in events: @@ -1642,9 +1762,12 @@ class Event(models.Model): eventIcal.add("summary", event.title) eventIcal.add("name", event.title) url = ("\n" + event.reference_urls[0]) if event.reference_urls and len(event.reference_urls) > 0 else "" + description = event.description if event.description else "" eventIcal.add( - "description", event.description + url + "description", description + url ) + if not event.local_image is None and event.local_image != "": + eventIcal.add('image', request.build_absolute_uri(event.local_image), parameters={'VALUE': 'URI'}) eventIcal.add("location", event.exact_location or event.location) cal.add_component(eventIcal) @@ -1680,10 +1803,18 @@ class Event(models.Model): return [Event.get_count_modification(w) for w in when_list] -class ContactMessage(models.Model): +class Message(models.Model): class Meta: - verbose_name = _("Contact message") - verbose_name_plural = _("Contact messages") + verbose_name = _("Message") + verbose_name_plural = _("Messages") + indexes = [ + models.Index(fields=['related_event']), + models.Index(fields=['user']), + models.Index(fields=['date']), + models.Index(fields=['spam', 'closed']), + ] + + subject = models.CharField( verbose_name=_("Subject"), @@ -1700,6 +1831,14 @@ class ContactMessage(models.Model): on_delete=models.SET_DEFAULT, ) + user = models.ForeignKey( + User, + verbose_name=_("Author of the message"), + null=True, + default=None, + on_delete=models.SET_DEFAULT, + ) + name = models.CharField( verbose_name=_("Name"), help_text=_("Your name"), @@ -1739,11 +1878,11 @@ class ContactMessage(models.Model): null=True, ) - def nb_open_contactmessages(): - return ContactMessage.objects.filter(closed=False).count() + def nb_open_messages(): + return Message.objects.filter(Q(closed=False)&Q(spam=False)).count() def get_absolute_url(self): - return reverse("contactmessage", kwargs={"pk": self.pk}) + return reverse("message", kwargs={"pk": self.pk}) class RecurrentImport(models.Model): @@ -1764,6 +1903,7 @@ class RecurrentImport(models.Model): FBEVENTS = "Facebook events", _("Événements d'une page FB") C3C = "cour3coquins", _("la cour des 3 coquins") ARACHNEE = "arachnee", _("Arachnée concert") + LERIO = "rio", _('Le Rio') class DOWNLOADER(models.TextChoices): SIMPLE = "simple", _("simple") @@ -1831,6 +1971,12 @@ class RecurrentImport(models.Model): blank=True, ) + forceLocation = models.BooleanField( + verbose_name=_("Force location"), + help_text=_("force location even if another is detected."), + default=False + ) + defaultOrganiser = models.ForeignKey( Organisation, verbose_name=_("Organiser"), @@ -1891,6 +2037,11 @@ class BatchImportation(models.Model): verbose_name = _("Batch importation") verbose_name_plural = _("Batch importations") permissions = [("run_batchimportation", "Can run a batch importation")] + indexes = [ + models.Index(fields=['created_date']), + models.Index(fields=['status']), + models.Index(fields=['created_date', 'recurrentImport']), + ] created_date = models.DateTimeField(auto_now_add=True) diff --git a/src/agenda_culturel/settings/base.py b/src/agenda_culturel/settings/base.py index 9df688b..8fdd6d4 100644 --- a/src/agenda_culturel/settings/base.py +++ b/src/agenda_culturel/settings/base.py @@ -56,9 +56,10 @@ INSTALLED_APPS = [ "robots", "debug_toolbar", "cache_cleaner", + "honeypot", ] -SITE_ID = 1 +HONEYPOT_FIELD_NAME = "alias_name" MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", @@ -72,6 +73,7 @@ MIDDLEWARE = [ "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "debug_toolbar.middleware.DebugToolbarMiddleware", + 'django.contrib.sites.middleware.CurrentSiteMiddleware', # "django.middleware.cache.UpdateCacheMiddleware", # "django.middleware.common.CommonMiddleware", # "django.middleware.cache.FetchFromCacheMiddleware", @@ -145,7 +147,7 @@ TIME_ZONE = "Europe/Paris" USE_I18N = True -USE_TZ = True +USE_TZ = False LANGUAGES = ( ("fr", _("French")), diff --git a/src/agenda_culturel/sitemaps.py b/src/agenda_culturel/sitemaps.py new file mode 100644 index 0000000..bc74502 --- /dev/null +++ b/src/agenda_culturel/sitemaps.py @@ -0,0 +1,13 @@ +from django.contrib import sitemaps +from django.urls import reverse + + +class StaticViewSitemap(sitemaps.Sitemap): + priority = 0.5 + changefreq = "daily" + + def items(self): + return ["home", "cette_semaine", "ce_mois_ci", "aujourdhui", "a_venir", "about", "contact"] + + def location(self, item): + return reverse(item) \ No newline at end of file diff --git a/src/agenda_culturel/static/images/capture.png b/src/agenda_culturel/static/images/capture.png deleted file mode 100644 index 203a3de..0000000 Binary files a/src/agenda_culturel/static/images/capture.png and /dev/null differ diff --git a/src/agenda_culturel/static/js/modal.js b/src/agenda_culturel/static/js/modal.js index 0182a5d..31a87cd 100644 --- a/src/agenda_culturel/static/js/modal.js +++ b/src/agenda_culturel/static/js/modal.js @@ -34,11 +34,17 @@ const openModal = (modal, back=true) => { } setTimeout(function() { visibleModal = modal; - }, 500); + + console.log("ici"); + const mask = visibleModal.querySelector(".h-mask"); + mask.classList.add("visible"); + }, 350); }; const hideModal = (modal) => { if (modal != null) { + const mask = visibleModal.querySelector(".h-mask"); + mask.classList.remove("visible"); visibleModal = null; document.documentElement.style.removeProperty("--scrollbar-width"); modal.removeAttribute("open"); diff --git a/src/agenda_culturel/static/location_field/js/form.js b/src/agenda_culturel/static/location_field/js/form.js new file mode 100644 index 0000000..85f9cc3 --- /dev/null +++ b/src/agenda_culturel/static/location_field/js/form.js @@ -0,0 +1,673 @@ +var SequentialLoader = function() { + var SL = { + loadJS: function(src, onload) { + //console.log(src); + // add to pending list + this._load_pending.push({'src': src, 'onload': onload}); + // check if not already loading + if ( ! this._loading) { + this._loading = true; + // load first + this.loadNextJS(); + } + }, + + loadNextJS: function() { + // get next + var next = this._load_pending.shift(); + if (next == undefined) { + // nothing to load + this._loading = false; + return; + } + // check not loaded + if (this._load_cache[next.src] != undefined) { + next.onload(); + this.loadNextJS(); + return; // already loaded + } + else { + this._load_cache[next.src] = 1; + } + // load + var el = document.createElement('script'); + el.type = 'application/javascript'; + el.src = next.src; + // onload callback + var self = this; + el.onload = function(){ + //console.log('Loaded: ' + next.src); + // trigger onload + next.onload(); + // try to load next + self.loadNextJS(); + }; + document.body.appendChild(el); + }, + + _loading: false, + _load_pending: [], + _load_cache: {} + }; + + return { + loadJS: SL.loadJS.bind(SL) + } +}; + + +!function($){ + var LocationFieldCache = { + load: [], + onload: {}, + + isLoading: false + }; + + var LocationFieldResourceLoader; + + $.locationField = function(options) { + var LocationField = { + options: $.extend({ + provider: 'google', + providerOptions: { + google: { + api: '//maps.google.com/maps/api/js', + mapType: 'ROADMAP' + } + }, + searchProvider: 'google', + id: 'map', + latLng: '0,0', + mapOptions: { + zoom: 9 + }, + basedFields: $(), + inputField: $(), + suffix: '', + path: '', + fixMarker: true + }, options), + + providers: /google|openstreetmap|mapbox/, + searchProviders: /google|yandex|nominatim|addok/, + + render: function() { + this.$id = $('#' + this.options.id); + + if ( ! this.providers.test(this.options.provider)) { + this.error('render failed, invalid map provider: ' + this.options.provider); + return; + } + + if ( ! this.searchProviders.test(this.options.searchProvider)) { + this.error('render failed, invalid search provider: ' + this.options.searchProvider); + return; + } + + var self = this; + + this.loadAll(function(){ + var mapOptions = self._getMapOptions(), + map = self._getMap(mapOptions); + + var marker = self._getMarker(map, mapOptions.center); + + // fix issue w/ marker not appearing + if (self.options.provider == 'google' && self.options.fixMarker) + self.__fixMarker(); + + // watch based fields + self._watchBasedFields(map, marker); + }); + }, + + fill: function(latLng) { + this.options.inputField.val(latLng.lat + ',' + latLng.lng); + }, + + search: function(map, marker, address) { + if (this.options.searchProvider === 'google') { + var provider = new GeoSearch.GoogleProvider({ apiKey: this.options.providerOptions.google.apiKey }); + provider.search({query: address}).then(data => { + if (data.length > 0) { + var result = data[0], + latLng = new L.LatLng(result.y, result.x); + + marker.setLatLng(latLng); + map.panTo(latLng); + } + }); + } + + else if (this.options.searchProvider === 'yandex') { + // https://yandex.com/dev/maps/geocoder/doc/desc/concepts/input_params.html + var url = 'https://geocode-maps.yandex.ru/1.x/?format=json&geocode=' + address; + + if (typeof this.options.providerOptions.yandex.apiKey !== 'undefined') { + url += '&apikey=' + this.options.providerOptions.yandex.apiKey; + } + + var request = new XMLHttpRequest(); + request.open('GET', url, true); + + request.onload = function () { + if (request.status >= 200 && request.status < 400) { + var data = JSON.parse(request.responseText); + var pos = data.response.GeoObjectCollection.featureMember[0].GeoObject.Point.pos.split(' '); + var latLng = new L.LatLng(pos[1], pos[0]); + marker.setLatLng(latLng); + map.panTo(latLng); + } else { + console.error('Yandex geocoder error response'); + } + }; + + request.onerror = function () { + console.error('Check connection to Yandex geocoder'); + }; + + request.send(); + } + + else if (this.options.searchProvider === 'addok') { + var url = 'https://api-adresse.data.gouv.fr/search/?limit=1&q=' + address; + + var request = new XMLHttpRequest(); + request.open('GET', url, true); + + request.onload = function () { + if (request.status >= 200 && request.status < 400) { + var data = JSON.parse(request.responseText); + var pos = data.features[0].geometry.coordinates; + var latLng = new L.LatLng(pos[1], pos[0]); + marker.setLatLng(latLng); + map.panTo(latLng); + } else { + console.error('Addok geocoder error response'); + } + }; + + request.onerror = function () { + console.error('Check connection to Addok geocoder'); + }; + + request.send(); + } + + else if (this.options.searchProvider === 'nominatim') { + var url = '//nominatim.openstreetmap.org/search?format=json&q=' + address; + + var request = new XMLHttpRequest(); + request.open('GET', url, true); + + request.onload = function () { + if (request.status >= 200 && request.status < 400) { + var data = JSON.parse(request.responseText); + if (data.length > 0) { + var pos = data[0]; + var latLng = new L.LatLng(pos.lat, pos.lon); + marker.setLatLng(latLng); + map.panTo(latLng); + } else { + console.error(address + ': not found via Nominatim'); + } + } else { + console.error('Nominatim geocoder error response'); + } + }; + + request.onerror = function () { + console.error('Check connection to Nominatim geocoder'); + }; + + request.send(); + } + }, + + loadAll: function(onload) { + this.$id.html('Loading...'); + + // resource loader + if (LocationFieldResourceLoader == undefined) + LocationFieldResourceLoader = SequentialLoader(); + + this.load.loader = LocationFieldResourceLoader; + this.load.path = this.options.path; + + var self = this; + + this.load.common(function(){ + var mapProvider = self.options.provider, + onLoadMapProvider = function() { + var searchProvider = self.options.searchProvider + 'SearchProvider', + onLoadSearchProvider = function() { + self.$id.html(''); + onload(); + }; + + if (self.load[searchProvider] != undefined) { + self.load[searchProvider](self.options.providerOptions[self.options.searchProvider] || {}, onLoadSearchProvider); + } + else { + onLoadSearchProvider(); + } + }; + + if (self.load[mapProvider] != undefined) { + self.load[mapProvider](self.options.providerOptions[mapProvider] || {}, onLoadMapProvider); + } + else { + onLoadMapProvider(); + } + }); + }, + + load: { + google: function(options, onload) { + var js = [ + this.path + '/@googlemaps/js-api-loader/index.min.js', + this.path + '/Leaflet.GoogleMutant.js', + ]; + + this._loadJSList(js, function(){ + const loader = new google.maps.plugins.loader.Loader({ + apiKey: options.apiKey, + version: "weekly", + }); + loader.load().then(() => onload()); + }); + }, + + googleSearchProvider: function(options, onload) { + onload(); + //var url = options.api; + + //if (typeof options.apiKey !== 'undefined') { + // url += url.indexOf('?') === -1 ? '?' : '&'; + // url += 'key=' + options.apiKey; + //} + + //var js = [ + // url, + // this.path + '/l.geosearch.provider.google.js' + // ]; + + //this._loadJSList(js, function(){ + // // https://github.com/smeijer/L.GeoSearch/issues/57#issuecomment-148393974 + // L.GeoSearch.Provider.Google.Geocoder = new google.maps.Geocoder(); + + // onload(); + //}); + }, + + yandexSearchProvider: function (options, onload) { + onload(); + }, + + mapbox: function(options, onload) { + onload(); + }, + + openstreetmap: function(options, onload) { + onload(); + }, + + common: function(onload) { + var self = this, + js = [ + // map providers + this.path + '/leaflet/leaflet.js', + // search providers + this.path + '/leaflet-geosearch/geosearch.umd.js', + ], + css = [ + // map providers + this.path + '/leaflet/leaflet.css' + ]; + + // Leaflet docs note: + // Include Leaflet JavaScript file *after* Leaflet’s CSS + // https://leafletjs.com/examples/quick-start/ + this._loadCSSList(css, function(){ + self._loadJSList(js, onload); + }); + }, + + _loadJS: function(src, onload) { + this.loader.loadJS(src, onload); + }, + + _loadJSList: function(srclist, onload) { + this.__loadList(this._loadJS, srclist, onload); + }, + + _loadCSS: function(src, onload) { + if (LocationFieldCache.onload[src] != undefined) { + onload(); + } + else { + LocationFieldCache.onload[src] = 1; + onloadCSS(loadCSS(src), onload); + } + }, + + _loadCSSList: function(srclist, onload) { + this.__loadList(this._loadCSS, srclist, onload); + }, + + __loadList: function(fn, srclist, onload) { + if (srclist.length > 1) { + for (var i = 0; i < srclist.length-1; ++i) { + fn.call(this, srclist[i], function(){}); + } + } + + fn.call(this, srclist[srclist.length-1], onload); + } + }, + + error: function(message) { + console.log(message); + this.$id.html(message); + }, + + _getMap: function(mapOptions) { + var map = new L.Map(this.options.id, mapOptions), layer; + + if (this.options.provider == 'google') { + layer = new L.gridLayer.googleMutant({ + type: this.options.providerOptions.google.mapType.toLowerCase(), + }); + } + else if (this.options.provider == 'openstreetmap') { + layer = new L.tileLayer( + '//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 18 + }); + } + else if (this.options.provider == 'mapbox') { + layer = new L.tileLayer( + 'https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}?access_token={accessToken}', { + maxZoom: 18, + accessToken: this.options.providerOptions.mapbox.access_token, + id: 'mapbox/streets-v11' + }); + } + + map.addLayer(layer); + + return map; + }, + + _getMapOptions: function() { + return $.extend(this.options.mapOptions, { + center: this._getLatLng() + }); + }, + + _getLatLng: function() { + var l = this.options.latLng.split(',').map(parseFloat); + return new L.LatLng(l[0], l[1]); + }, + + _getMarker: function(map, center) { + var self = this, + markerOptions = { + draggable: true + }; + + var marker = L.marker(center, markerOptions).addTo(map); + + marker.on('dragstart', function(){ + if (self.options.inputField.is('[readonly]')) + marker.dragging.disable(); + else + marker.dragging.enable(); + }); + + // fill input on dragend + marker.on('dragend move', function(){ + if (!self.options.inputField.is('[readonly]')) + self.fill(this.getLatLng()); + }); + + // place marker on map click + map.on('click', function(e){ + if (!self.options.inputField.is('[readonly]')) { + marker.setLatLng(e.latlng); + marker.dragging.enable(); + } + }); + + return marker; + }, + + _watchBasedFields: function(map, marker) { + var self = this, + basedFields = this.options.basedFields, + onchangeTimer, + onchange = function() { + if (!self.options.inputField.is('[readonly]')) { + var values = basedFields.map(function() { + var value = $(this).val(); + return value === '' ? null : value; + }); + var address = values.toArray().join(', '); + clearTimeout(onchangeTimer); + onchangeTimer = setTimeout(function(){ + self.search(map, marker, address); + }, 300); + } + }; + + basedFields.each(function(){ + var el = $(this); + + if (el.is('select')) + el.change(onchange); + else + el.keyup(onchange); + }); + + if (this.options.inputField.val() === '') { + var values = basedFields.map(function() { + var value = $(this).val(); + return value === '' ? null : value; + }); + var address = values.toArray().join(', '); + if (address !== '') + onchange(); + } + }, + + __fixMarker: function() { + $('.leaflet-map-pane').css('z-index', '2 !important'); + $('.leaflet-google-layer').css('z-index', '1 !important'); + } + } + + return { + render: LocationField.render.bind(LocationField) + } + } + + function dataLocationFieldObserver(callback) { + function _findAndEnableDataLocationFields() { + var dataLocationFields = $('input[data-location-field-options]'); + + dataLocationFields + .filter(':not([data-location-field-observed])') + .attr('data-location-field-observed', true) + .each(callback); + } + + var observer = new MutationObserver(function(mutations){ + _findAndEnableDataLocationFields(); + }); + + var container = document.documentElement || document.body; + + $(container).ready(function(){ + _findAndEnableDataLocationFields(); + }); + + observer.observe(container, {attributes: true}); + } + + dataLocationFieldObserver(function(){ + var el = $(this); + + var name = el.attr('name'), + options = el.data('location-field-options'), + basedFields = options.field_options.based_fields, + pluginOptions = { + id: 'map_' + name, + inputField: el, + latLng: el.val() || '0,0', + suffix: options['search.suffix'], + path: options['resources.root_path'], + provider: options['map.provider'], + searchProvider: options['search.provider'], + providerOptions: { + google: { + api: options['provider.google.api'], + apiKey: options['provider.google.api_key'], + mapType: options['provider.google.map_type'] + }, + mapbox: { + access_token: options['provider.mapbox.access_token'] + }, + yandex: { + apiKey: options['provider.yandex.api_key'] + }, + }, + mapOptions: { + zoom: options['map.zoom'] + } + }; + + // prefix + var prefixNumber; + + try { + prefixNumber = name.match(/-(\d+)-/)[1]; + } catch (e) {} + + if (options.field_options.prefix) { + var prefix = options.field_options.prefix; + + if (prefixNumber != null) { + prefix = prefix.replace(/__prefix__/, prefixNumber); + } + + basedFields = basedFields.map(function(n){ + return prefix + n + }); + } + + // based fields + pluginOptions.basedFields = $(basedFields.map(function(n){ + return '#id_' + n + }).join(',')); + + // render + $.locationField(pluginOptions).render(); + }); + +}(jQuery || django.jQuery); + +/*! +loadCSS: load a CSS file asynchronously. +[c]2015 @scottjehl, Filament Group, Inc. +Licensed MIT +*/ +(function(w){ + "use strict"; + /* exported loadCSS */ + var loadCSS = function( href, before, media ){ + // Arguments explained: + // `href` [REQUIRED] is the URL for your CSS file. + // `before` [OPTIONAL] is the element the script should use as a reference for injecting our stylesheet before + // By default, loadCSS attempts to inject the link after the last stylesheet or script in the DOM. However, you might desire a more specific location in your document. + // `media` [OPTIONAL] is the media type or query of the stylesheet. By default it will be 'all' + var doc = w.document; + var ss = doc.createElement( "link" ); + var ref; + if( before ){ + ref = before; + } + else { + var refs = ( doc.body || doc.getElementsByTagName( "head" )[ 0 ] ).childNodes; + ref = refs[ refs.length - 1]; + } + + var sheets = doc.styleSheets; + ss.rel = "stylesheet"; + ss.href = href; + // temporarily set media to something inapplicable to ensure it'll fetch without blocking render + ss.media = "only x"; + + // Inject link + // Note: the ternary preserves the existing behavior of "before" argument, but we could choose to change the argument to "after" in a later release and standardize on ref.nextSibling for all refs + // Note: `insertBefore` is used instead of `appendChild`, for safety re: http://www.paulirish.com/2011/surefire-dom-element-insertion/ + ref.parentNode.insertBefore( ss, ( before ? ref : ref.nextSibling ) ); + // A method (exposed on return object for external use) that mimics onload by polling until document.styleSheets until it includes the new sheet. + var onloadcssdefined = function( cb ){ + var resolvedHref = ss.href; + var i = sheets.length; + while( i-- ){ + if( sheets[ i ].href === resolvedHref ){ + return cb(); + } + } + setTimeout(function() { + onloadcssdefined( cb ); + }); + }; + + // once loaded, set link's media back to `all` so that the stylesheet applies once it loads + ss.onloadcssdefined = onloadcssdefined; + onloadcssdefined(function() { + ss.media = media || "all"; + }); + return ss; + }; + // commonjs + if( typeof module !== "undefined" ){ + module.exports = loadCSS; + } + else { + w.loadCSS = loadCSS; + } +}( typeof global !== "undefined" ? global : this )); + + +/*! +onloadCSS: adds onload support for asynchronous stylesheets loaded with loadCSS. +[c]2014 @zachleat, Filament Group, Inc. +Licensed MIT +*/ + +/* global navigator */ +/* exported onloadCSS */ +function onloadCSS( ss, callback ) { + ss.onload = function() { + ss.onload = null; + if( callback ) { + callback.call( ss ); + } + }; + + // This code is for browsers that don’t support onload, any browser that + // supports onload should use that instead. + // No support for onload: + // * Android 4.3 (Samsung Galaxy S4, Browserstack) + // * Android 4.2 Browser (Samsung Galaxy SIII Mini GT-I8200L) + // * Android 2.3 (Pantech Burst P9070) + + // Weak inference targets Android < 4.4 + if( "isApplicationInstalled" in navigator && "onloadcssdefined" in ss ) { + ss.onloadcssdefined( callback ); + } +} diff --git a/src/agenda_culturel/static/style.scss b/src/agenda_culturel/static/style.scss index 4947696..f13b9d3 100644 --- a/src/agenda_culturel/static/style.scss +++ b/src/agenda_culturel/static/style.scss @@ -44,6 +44,9 @@ $enable-responsive-typography: true; // Modal () --modal-overlay-backdrop-filter: blur(0.05rem); + --background-color-transparent: color-mix(in srgb, var(--background-color), transparent 30%); + + --background-color-transparent-light: color-mix(in srgb, var(--background-color), transparent 80%); } @@ -329,6 +332,7 @@ footer [data-tooltip] { scroll-behavior: smooth; transition-duration: 200ms; + .cat { margin-right: 0; } @@ -1434,6 +1438,17 @@ img.preview { } } +.header-complement { + float: none; +} + +@media only screen and (min-width: 992px) { + .header-complement { + float: left; + clear: both; + } +} + form.messages div, form.moderation-events { @media only screen and (min-width: 992px) { display: grid; @@ -1454,6 +1469,11 @@ form.messages div, form.moderation-events { overflow-y: auto; } +#moderate-form #id_status { + display: grid; + grid-template-columns: repeat(3, 1fr); +} + label.required::after { content: ' *'; color: red; @@ -1502,3 +1522,228 @@ label.required::after { .maskable_group .body_group.closed { display: none; } + +.form-place { + display: grid; + grid-template-columns: repeat(1, 1fr); + row-gap: .5em; + margin-bottom: 0.5em; + .map-widget { + grid-row: 3; + } + #group_address .body_group { + display: grid; + grid-template-columns: repear(2, 1fr); + + column-gap: .5em; + #div_id_address, #div_id_location { + grid-column: 1 / 3; + } + + } +} +@media only screen and (min-width: 992px) { + .form-place { + grid-template-columns: repeat(2, 1fr); + + .map-widget { + grid-column: 2 / 3; + grid-row: 1 / 3; + } + + #group_other { + grid-column: 1 / 3; + } + } +} + +.line-now { + font-size: 60%; + div { + display: grid; + grid-template-columns: fit-content(2em) auto; + column-gap: .2em; + color: red; + .line { + margin-top: .7em; + border-top: 1px solid red; + } + } + margin-bottom: 0; + list-style: none; +} +.a-venir .line-now { + margin-left: -2em; + +} + +#chronology { + .entree { + display: grid; + grid-template-columns: fit-content(2em) auto; + column-gap: .7em; + .texte { + background: var(--background-color); + padding: 0.1em 0.8em; + border-radius: var(--border-radius); + p { + font-size: 100%; + } + p:last-child { + margin-bottom: 0.1em; + } + } + } + font-size: 85%; + footer { + margin-top: 1.8em; + padding: 0.2em .8em; + } + .ts { + @extend .badge-small; + border-radius: var(--border-radius); + display: inline-block; + width: 14em; + margin-right: 1.2em; + } +} + +.moderation_heatmap { + overflow-x: auto; + table { + max-width: 600px; + margin: auto; + .total, .month { + display: none; + } + .label { + display: none; + } + + th { + font-size: 90%; + text-align: center; + } + td { + font-size: 80%; + text-align: center; + } + tbody th { + text-align: right; + } + .ratio { + padding: 0.1em; + a, .a { + margin: auto; + border-radius: var(--border-radius); + color: black; + padding: 0; + display: block; + max-width: 6em; + width: 3.2em; + height: 2em; + line-height: 2em; + text-decoration: none; + } + } + .score_0 { + a, .a { + background: rgb(0, 128, 0); + } + a:hover { + background: rgb(0, 176, 0); + } + } + .score_1 { + a, .a { + background: rgb(255, 255, 0); + } + a:hover { + background: rgb(248, 248, 121); + } + } + .score_2 { + a, .a { + background: rgb(255, 166, 0); + } + a:hover { + background: rgb(255, 182, 47); + } + } + .score_3 { + a, .a { + background: rgb(255, 0, 0); + } + a:hover { + background: rgb(255, 91, 91); + } + } + .score_4 { + a, .a { + background: rgb(128, 0, 128); + color: white; + } + a:hover { + background: rgb(178, 0, 178); + } + } + @media only screen and (min-width: 1800px) { + .total, .month { + display: inline; + opacity: .35; + } + .label { + display: table-cell; + } + } + @media only screen and (min-width: 1600px) { + .ratio { + a, .a { + width: 5em; + height: 3.2em; + line-height: 3.2em; + } + } + font-size: 100%; + } + } +} + +dialog { + .h-image { + background-repeat: no-repeat; + background-size: cover; + background-position: center, center; + } + + .h-mask { + background-color: var(--background-color); + margin: calc(var(--spacing) * -1.5); + padding: calc(var(--spacing) * 1.5); + } + .h-mask.visible { + background-color: var(--background-color-transparent); + transition: background-color .8s ease-in; + } + .h-mask.visible:hover { + background-color: var(--background-color-transparent-light); + } +} + +.visible-link { + text-decoration: underline; +} +.detail-link { + text-align: right; + padding-right: 0.4em; + .visible-link { + color: var(--contrast); + } +} +.week-in-month { + article { + .visible-link { + color: var(--contrast); + } + } +} \ No newline at end of file diff --git a/src/agenda_culturel/templates/agenda_culturel/administration.html b/src/agenda_culturel/templates/agenda_culturel/administration.html index dbf94dd..795c496 100644 --- a/src/agenda_culturel/templates/agenda_culturel/administration.html +++ b/src/agenda_culturel/templates/agenda_culturel/administration.html @@ -17,13 +17,53 @@ {% block content %}
    +
    -
    -
    -
    - Modérer {% picto_from_name "check-square" %} + +
    +
    + +

    Modération à venir

    +
    +
    +
    + {% url 'administration' as local_url %} + {% include "agenda_culturel/static_content.html" with name="administration" url_path=local_url %} +
    +
    + {% for w in nb_not_moderated %} + + + + {% for m in w %} + + {% endfor %} + + + + + {% endfor %} + + +
    {{ m.start_day|date:"d" }} {{ m.start_day|date:"M"|lower }}
    reste à modérer + {% for m in w %} + + <{% if m.not_moderated > 0 %}a href="{% if m.is_today %} + {% url 'moderate' %} + {% else %} + {% url 'moderate_from_date' m.start_day.year m.start_day.month m.start_day.day %} + {% endif %}"{% else %}span class="a"{% endif %}> + {{ m.not_moderated }} / {{ m.nb_events }} 0 %}a{% else %}span{% endif %}> +
    + {% endfor %} +
    +
    +
    +

    Activité des derniers jours

    Résumé des activités

    @@ -36,7 +76,8 @@ {% include "agenda_culturel/rimports-info-inc.html" with all=1 %}

    - +
    +
    diff --git a/src/agenda_culturel/templates/agenda_culturel/clear_cache.html b/src/agenda_culturel/templates/agenda_culturel/clear_cache.html new file mode 100644 index 0000000..ba440c7 --- /dev/null +++ b/src/agenda_culturel/templates/agenda_culturel/clear_cache.html @@ -0,0 +1,27 @@ +{% extends "agenda_culturel/page-admin.html" %} + + +{% block fluid %}{% endblock %} + +{% block content %} + +
    +
    +

    {% block title %}{% block og_title %}Vider le cache{% endblock %}{% endblock %}

    +
    +
    {% csrf_token %} +

    Êtes-vous sûr·e de vouloir vider le cache ? Toutes les pages seront + générées lors de leur consultation, mais cela peut ralentir temporairemenet l'expérience de navigation. +

    + {{ form }} + + +
    +
    + +{% endblock %} \ No newline at end of file diff --git a/src/agenda_culturel/templates/agenda_culturel/edit-buttons-inc.html b/src/agenda_culturel/templates/agenda_culturel/edit-buttons-inc.html index 23c6f6a..bef57a0 100644 --- a/src/agenda_culturel/templates/agenda_culturel/edit-buttons-inc.html +++ b/src/agenda_culturel/templates/agenda_culturel/edit-buttons-inc.html @@ -12,7 +12,7 @@ {% if local %} voir la version locale {% picto_from_name "eye" %} {% else %} - créer une copie locale {% picto_from_name "plus-circle" %} + modifier en copie locale {% picto_from_name "plus-circle" %} {% endif %} {% endwith %} {% else %} diff --git a/src/agenda_culturel/templates/agenda_culturel/event-date-info-inc.html b/src/agenda_culturel/templates/agenda_culturel/event-date-info-inc.html index 9a84b6a..4b6a21a 100644 --- a/src/agenda_culturel/templates/agenda_culturel/event-date-info-inc.html +++ b/src/agenda_culturel/templates/agenda_culturel/event-date-info-inc.html @@ -1,13 +1,13 @@ {% if user.is_authenticated %} -
    - Informations complémentaires non éditables : + Informations complémentaires non éditables
      - {% if object.created_date %}
    • Création : {{ object.created_date }}
    • {% endif %} - {% if object.modified_date %}
    • Dernière modification : {{ object.modified_date }}
    • {% endif %} - {% if object.moderated_date %}
    • Dernière modération : {{ object.moderated_date }}
    • {% endif %} - {% if object.imported_date %}
    • Dernière importation : {{ object.imported_date }}
    • {% endif %} + {% if not allbutdates %} + {% if object.created_date %}
    • Création : {{ object.created_date }}{% if object.created_by_user %} par {{ object.created_by_user.username }}{% endif %}
    • {% endif %} + {% if object.modified_date %}
    • Dernière modification : {{ object.modified_date }}{% if object.modified_by_user %} par {{ object.modified_by_user.username }}{% endif %}
    • {% endif %} + {% if object.moderated_date %}
    • Dernière modération : {{ object.moderated_date }}{% if object.moderated_by_user %} par {{ object.moderated_by_user.username }}{% endif %}
    • {% endif %} + {% if object.imported_date %}
    • Dernière importation : {{ object.imported_date }}{% if object.imported_by_user %} par {{ object.imported_by_user.username }}{% endif %}
    • {% endif %} + {% endif %} {% if object.uuids %} {% if object.uuids|length > 0 %}
    • UUIDs (identifiants uniques d'événements dans les sources) : diff --git a/src/agenda_culturel/templates/agenda_culturel/event_form.html b/src/agenda_culturel/templates/agenda_culturel/event_form.html index fbc1128..dfe2649 100644 --- a/src/agenda_culturel/templates/agenda_culturel/event_form.html +++ b/src/agenda_culturel/templates/agenda_culturel/event_form.html @@ -81,7 +81,7 @@ Duplication de {% else %} {{ form }}
      Annuler - +
      diff --git a/src/agenda_culturel/templates/agenda_culturel/event_form_moderate.html b/src/agenda_culturel/templates/agenda_culturel/event_form_moderate.html index e557433..3352163 100644 --- a/src/agenda_culturel/templates/agenda_culturel/event_form_moderate.html +++ b/src/agenda_culturel/templates/agenda_culturel/event_form_moderate.html @@ -33,31 +33,39 @@

    -
    {% csrf_token %} +{% csrf_token %}
    - {% include "agenda_culturel/single-event/event-single-inc.html" with event=event noedit=1 %} +
    + {% include "agenda_culturel/single-event/event-single-inc.html" with event=event onlyedit=1 %} + + {% with event.get_concurrent_events as concurrent_events %} + {% if concurrent_events %} +
    +
    +

    En même temps

    +

    {% if concurrent_events|length > 1 %}Plusieurs événements se déroulent en même temps.{% else %}Un autre événement se déroule en même temps.{% endif %}

    +
    +
      + {% for e in concurrent_events %} +
    • + {{ e.category|circle_cat }} {% if e.start_time %}{{ e.start_time }}{% else %}toute la journée{% endif %} {{ e.title }} +
    • + {% endfor %} +
    +
    + {% endif %} + {% endwith %} + +
    -
    - {% with event.get_local_version as local %} - {% if local %} - {% if event != local %} - - {% else %} - - {% endif %} - {% else %} - - {% endif %} - {% endwith %} -

    Modification des méta-informations

    {% if event.moderated_date %} -

    Cet événement a déjà été modéré par le {{ event.moderated_date }}. +

    Cet événement a déjà été modéré {% if event.moderation_by_user %}par {{ event.moderation_by_user.username }} {% endif %}le {{ event.moderated_date }}. Vous pouvez bien sûr modifier de nouveau ces méta-informations en utilisant le formulaire ci-après.

    @@ -69,23 +77,13 @@
    {% if pred %} - 🠄 Revenir au précédent + < Revenir au précédent {% else %} Annuler {% endif %} - {% with event.get_local_version as local %} - {% if local %} - {% if local == event %} - - {% else %} - - {% endif %} - {% else %} - - {% endif %} - {% endwith %} - + + Passer au suivant sans enregistrer >
    diff --git a/src/agenda_culturel/templates/agenda_culturel/contactmessage_confirm_delete.html b/src/agenda_culturel/templates/agenda_culturel/message_confirm_delete.html similarity index 100% rename from src/agenda_culturel/templates/agenda_culturel/contactmessage_confirm_delete.html rename to src/agenda_culturel/templates/agenda_culturel/message_confirm_delete.html diff --git a/src/agenda_culturel/templates/agenda_culturel/contactmessage_create_form.html b/src/agenda_culturel/templates/agenda_culturel/message_create_form.html similarity index 84% rename from src/agenda_culturel/templates/agenda_culturel/contactmessage_create_form.html rename to src/agenda_culturel/templates/agenda_culturel/message_create_form.html index e91208d..d3acd2b 100644 --- a/src/agenda_culturel/templates/agenda_culturel/contactmessage_create_form.html +++ b/src/agenda_culturel/templates/agenda_culturel/message_create_form.html @@ -1,5 +1,6 @@ {% extends "agenda_culturel/page-admin.html" %} {% load static %} +{% load honeypot %} {% block title %}{% block og_title %}{% if form.event %}Contact au sujet de l'événement {{ form.event.title }}{% else %} Contact{% endif %}{% endblock %}{% endblock %} @@ -31,7 +32,7 @@ Contact{% endif %}{% endblock %}{% endblock %}
    {% endif %} -

    Attention : n'utilisez pas le formulaire ci-dessous pour proposer un événement, il sera ignoré. Utilisez plutôt la page ajouter un événement.

    +

    Attention : n'utilisez pas le formulaire ci-dessous pour proposer un événement, il sera ignoré. Utilisez plutôt la page ajouter un événement.

    {% if form.event %}

    Tu nous contactes au sujet de l'événement « {{ form.event.title }} » du {{ form.event.start_day }}. N'hésites pas à nous indiquer le maximum de contexte et à nous laisser ton adresse @@ -40,6 +41,7 @@ Contact{% endif %}{% endblock %}{% endblock %} {% endif %}

    {% csrf_token %} + {% render_honeypot_field "alias_name" %} {{ form.media }} {{ form.as_p }} diff --git a/src/agenda_culturel/templates/agenda_culturel/contactmessage_moderation_form.html b/src/agenda_culturel/templates/agenda_culturel/message_moderation_form.html similarity index 79% rename from src/agenda_culturel/templates/agenda_culturel/contactmessage_moderation_form.html rename to src/agenda_culturel/templates/agenda_culturel/message_moderation_form.html index 52bc8fe..9ed886a 100644 --- a/src/agenda_culturel/templates/agenda_culturel/contactmessage_moderation_form.html +++ b/src/agenda_culturel/templates/agenda_culturel/message_moderation_form.html @@ -24,13 +24,13 @@

    Modération du message « {{ object.subject }} »

    • Date : {{ object.date.date }} à {{ object.date.time }}
    • -
    • Auteur : {{ object.name }} {% if object.email %}{{ object.email }}{% endif %}
    • +
    • Auteur : {% if object.user %}{{ object.user }}{% else %}{{ object.name }}{% endif %} {% if object.email %}{{ object.email }}{% endif %}
    • {% if object.related_event %}
    • Événement associé : {{ object.related_event.title }} du {{ object.related_event.start_day }}
    • {% endif %}
    @@ -47,7 +47,7 @@
    -{% include "agenda_culturel/side-nav.html" with current="contactmessages" %} +{% include "agenda_culturel/side-nav.html" with current="messages" %}
    {% endblock %} \ No newline at end of file diff --git a/src/agenda_culturel/templates/agenda_culturel/contactmessages.html b/src/agenda_culturel/templates/agenda_culturel/messages.html similarity index 88% rename from src/agenda_culturel/templates/agenda_culturel/contactmessages.html rename to src/agenda_culturel/templates/agenda_culturel/messages.html index b50e379..257fcbf 100644 --- a/src/agenda_culturel/templates/agenda_culturel/contactmessages.html +++ b/src/agenda_culturel/templates/agenda_culturel/messages.html @@ -45,8 +45,8 @@ {% for obj in paginator_filter %} {{ obj.date }} - {{ obj.subject }} - {{ obj.name }} + {{ obj.subject }} + {% if obj.user %}{{ obj.user }}{% else %}{{ obj.name }}{% endif %} {% if obj.related_event %}{{ obj.related_event.pk }}{% else %}/{% endif %} {% if obj.closed %}{% picto_from_name "check-square" "fermé" %}{% else %}{% picto_from_name "square" "ouvert" %}{% endif %} {% if obj.spam %}{% picto_from_name "check-square" "spam" %}{% else %}{% picto_from_name "square" "non spam" %}{% endif %} @@ -59,7 +59,7 @@ -{% include "agenda_culturel/side-nav.html" with current="contactmessages" %} +{% include "agenda_culturel/side-nav.html" with current="messages" %}
    {% endblock %} \ No newline at end of file diff --git a/src/agenda_culturel/templates/agenda_culturel/organisation_form.html b/src/agenda_culturel/templates/agenda_culturel/organisation_form.html index 07116f5..c358c5b 100644 --- a/src/agenda_culturel/templates/agenda_culturel/organisation_form.html +++ b/src/agenda_culturel/templates/agenda_culturel/organisation_form.html @@ -35,12 +35,8 @@ const places = document.querySelector('#id_principal_place'); const choices_places = new Choices(places, { - placeholderValue: 'Sélectionner le lieu principal ', - allowHTML: true, - delimiter: ',', - removeItemButton: true, shouldSort: false, - } + } ); diff --git a/src/agenda_culturel/templates/agenda_culturel/page-event.html b/src/agenda_culturel/templates/agenda_culturel/page-event.html index 142defe..81bbdcf 100644 --- a/src/agenda_culturel/templates/agenda_culturel/page-event.html +++ b/src/agenda_culturel/templates/agenda_culturel/page-event.html @@ -3,10 +3,11 @@ {% load cat_extra %} {% load utils_extra %} {% load event_extra %} +{% load cache %} {% block title %}{% block og_title %}{{ event.title }}{% endblock %}{% endblock %} -{% block og_image %}{% if event.has_image_url %}{{ event.get_image_url }}{% else %}{{ block.super }}{% endif %}{% endblock %} +{% block og_image %}{% if event.has_image_url %}{{ event|get_image_uri:request }}{% else %}{{ block.super }}{% endif %}{% endblock %} {% block og_description %}{% if event.description %}{{ event.description |truncatewords:20|linebreaks }}{% else %}{{ block.super }}{% endif %}{% endblock %} {% block entete_header %} @@ -16,21 +17,59 @@ {% block content %} + +
    + {% with cache_timeout=user.is_authenticated|yesno:"30,600" %} + {% cache cache_timeout event_body user.is_authenticated event %} {% include "agenda_culturel/single-event/event-single-inc.html" with event=event filter=filter %} + {% endcache %} + {% endwith %} {% if user.is_authenticated %} -
    +
    -

    Informations internes

    +

    Chronologie

    - {% include "agenda_culturel/event-info-inc.html" with object=event %} + {% for step in event.chronology %} + {% if step.is_date %} + + {% else %} +
    +
    {{ step.timestamp }}
    +
    +
    Message{% if step.data.related_event and event != step.data.related_event %} sur une autre version{% endif %} : {{ step.data.subject|truncatechars:20 }} {% if step.data.user %} par {{ step.data.user }}{% else %} par {{ step.data.name }}{% if step.data.email %} ({{ step.data.email }}){% endif %}{% endif %}
    +
    {{ step.data.message|safe }}
    + {% if step.data.comments %} +
    Commentaire : {{ step.data.comments }}
    + {% endif %} +
    +
    + {% endif %} + {% endfor %} + {% csrf_token %} + {{ form.media }} + {{ form.as_p }} + + + + {% include "agenda_culturel/event-info-inc.html" with allbutdates=1 %}
    {% endif %}
    + {% with cache_timeout=user.is_authenticated|yesno:"30,600" %} + {% cache cache_timeout event_aside user.is_authenticated event %} - + {% endcache %} + {% endwith %}
    + {% endblock %} \ No newline at end of file diff --git a/src/agenda_culturel/templates/agenda_culturel/page-month.html b/src/agenda_culturel/templates/agenda_culturel/page-month.html index 993ee83..1d52c97 100644 --- a/src/agenda_culturel/templates/agenda_culturel/page-month.html +++ b/src/agenda_culturel/templates/agenda_culturel/page-month.html @@ -84,7 +84,7 @@ {% endif %} -

    {{ day.date | date:"l j" }} +

    {{ day.date | date:"l j" }} {% if day.events %}
      @@ -121,7 +121,8 @@

    {% endfor %} - {% endif %} diff --git a/src/agenda_culturel/templates/agenda_culturel/page-rimport.html b/src/agenda_culturel/templates/agenda_culturel/page-rimport.html index e385d61..a050df8 100644 --- a/src/agenda_culturel/templates/agenda_culturel/page-rimport.html +++ b/src/agenda_culturel/templates/agenda_culturel/page-rimport.html @@ -40,7 +40,7 @@
  • Valeurs par défaut :
    • Publié : {{ object.defaultPublished|yesno:"Oui,Non" }}
    • - {% if object.defaultLocation %}
    • Localisation : {{ object.defaultLocation }}
    • {% endif %} + {% if object.defaultLocation %}
    • Localisation{% if object.forceLocation %} (forcée){% endif %} : {{ object.defaultLocation }}
    • {% endif %}
    • Catégorie : {{ object.defaultCategory }}
    • {% if object.defaultOrganiser %}
    • Organisateur : {{ object.defaultOrganiser }}
    • {% endif %}
    • Étiquettes : diff --git a/src/agenda_culturel/templates/agenda_culturel/page-upcoming.html b/src/agenda_culturel/templates/agenda_culturel/page-upcoming.html index c1a1fcc..3ae0bf8 100644 --- a/src/agenda_culturel/templates/agenda_culturel/page-upcoming.html +++ b/src/agenda_culturel/templates/agenda_culturel/page-upcoming.html @@ -95,6 +95,9 @@

      {{ ti.short_name }} {{ ti.events|length }} {% picto_from_name "chevrons-down" %}

        {% for event in ti.events %} + {% if event.is_first_after_now %} +
      • {% now "H:i" %}
      • + {% endif %}
      • {{ event.category | circle_cat:event.has_recurrences }} {% if event.start_time %} {{ event.start_time }} @@ -102,6 +105,9 @@ {{ event|picto_status }} {{ event.title|no_emoji }} {{ event|tw_badge }}
      • {% endfor %} + {% if forloop.last and cd.is_today_after_events %} +
      • {% now "H:i" %}
      • + {% endif %}
      {% endif %} {% endfor %} diff --git a/src/agenda_culturel/templates/agenda_culturel/page-week.html b/src/agenda_culturel/templates/agenda_culturel/page-week.html index c078f32..e463b6e 100644 --- a/src/agenda_culturel/templates/agenda_culturel/page-week.html +++ b/src/agenda_culturel/templates/agenda_culturel/page-week.html @@ -37,7 +37,7 @@
      {% if calendar.firstdate|shift_day:-1|not_before_first %} {% if calendar.lastdate|not_after_last %} - + {% picto_from_name "chevron-left" %} précédente {% endif %} {% endif %} @@ -45,7 +45,7 @@ {% if calendar.lastdate|shift_day:+1|not_after_last %} {% if calendar.lastdate|not_before_first %} @@ -57,7 +57,7 @@ {% if calendar.firstdate|shift_day:-1|not_before_first %} {% if calendar.lastdate|not_after_last %} - + {% endif %} {% endif %} @@ -80,11 +80,14 @@ {% endif %} -

      {{ day.date | date:"l j" }}

      +

      {{ day.date | date:"l j" }}

      {% if day.events %} {% endif %}
    @@ -160,7 +173,7 @@ {% if calendar.lastdate|shift_day:+1|not_after_last %} {% if calendar.lastdate|not_before_first %} - + {% endif %} {% endif %} diff --git a/src/agenda_culturel/templates/agenda_culturel/page.html b/src/agenda_culturel/templates/agenda_culturel/page.html index 7483394..3016fc1 100644 --- a/src/agenda_culturel/templates/agenda_culturel/page.html +++ b/src/agenda_culturel/templates/agenda_culturel/page.html @@ -1,5 +1,11 @@ + {% load event_extra %} + {% load cache %} + {% load messages_extra %} + {% load utils_extra %} + {% load duplicated_extra %} + {% load rimports_extra %} @@ -9,7 +15,7 @@ {% load static %} - + {% if debug %} @@ -27,12 +33,7 @@ {% block entete_header %} {% endblock %} -{% load event_extra %} -{% load cache %} -{% load contactmessages_extra %} -{% load utils_extra %} -{% load duplicated_extra %} -{% load rimports_extra %} +
    @@ -76,8 +77,8 @@ {% if perms.agenda_culturel.change_place and perms.agenda_culturel.change_event %} {% show_badge_unknown_places "bottom" %} {% endif %} - {% if perms.agenda_culturel.view_contactmessage %} - {% show_badge_contactmessages "bottom" %} + {% if perms.agenda_culturel.view_message %} + {% show_badge_messages "bottom" %} {% endif %} {% if user.is_authenticated %} {{ user.username }} @ diff --git a/src/agenda_culturel/templates/agenda_culturel/place_form.html b/src/agenda_culturel/templates/agenda_culturel/place_form.html index f7bc4dc..b034fd2 100644 --- a/src/agenda_culturel/templates/agenda_culturel/place_form.html +++ b/src/agenda_culturel/templates/agenda_culturel/place_form.html @@ -22,9 +22,26 @@
    {% if event %}

    Création d'un lieu depuis l'événement « {{ event }} » (voir en bas de page le détail de l'événement).

    +

    Remarque : les champs ont été pré-remplis à partir de la description sous forme libre et n'est probablement pas parfaite.

    {% endif %}
    {% csrf_token %} - {{ form.as_grid }} +
    + {{ form }} +
    +
    +

    Cliquez pour ajuster la position GPS

    + Verrouiller la position + +
    +
    Annuler diff --git a/src/agenda_culturel/templates/agenda_culturel/side-nav.html b/src/agenda_culturel/templates/agenda_culturel/side-nav.html index dc8e4c6..addf657 100644 --- a/src/agenda_culturel/templates/agenda_culturel/side-nav.html +++ b/src/agenda_culturel/templates/agenda_culturel/side-nav.html @@ -1,5 +1,5 @@ {% load event_extra %} -{% load contactmessages_extra %} +{% load messages_extra %} {% load duplicated_extra %} {% load utils_extra %}
    +
    {% if perms.agenda_culturel.change_event %} {% include "agenda_culturel/edit-buttons-inc.html" with event=event %} {% endif %}
    -
    diff --git a/src/agenda_culturel/templates/agenda_culturel/single-event/event-single-inc.html b/src/agenda_culturel/templates/agenda_culturel/single-event/event-single-inc.html index 7482ee2..c3c36e6 100644 --- a/src/agenda_culturel/templates/agenda_culturel/single-event/event-single-inc.html +++ b/src/agenda_culturel/templates/agenda_culturel/single-event/event-single-inc.html @@ -22,16 +22,17 @@ {% endif %} {% if event.end_time %} {% if not event.end_day|date|frdate or event.end_day == event.start_day %}jusqu'à{% endif %} {{ event.end_time }}{% endif %}

    -

    - {% picto_from_name "map-pin" %} + {% if event.exact_location %} - {{ event.exact_location.name }}, {{ event.exact_location.city }} +

    {% picto_from_name "map-pin" %} + {{ event.exact_location.name }}, {{ event.exact_location.city }}

    {% else %} {% if perms.agenda_culturel.change_event and perms.agenda_culturel.change_place %} - {{ event.location }} +

    {% picto_from_name "map-pin" %} + {% if event.location %}{{ event.location }}{% else %}sans lieu{% endif %}

    {% else %} - {{ event.location }} + {% if event.location %}

    {% picto_from_name "map-pin" %} {{ event.location }}

    {% endif %} {% endif %} {% endif %}

    @@ -75,10 +76,10 @@

    {% endif %} {% endif %} - {% if perms.agenda_culturel.change_contactmessage %} - {% if event.contactmessage_set.all.count > 0 %} -

    Cet événement a fait l'objet {% if event.contactmessage_set.all.count == 1 %}d'un signalement{% else %}de signalements{% endif %} - {% for cm in event.contactmessage_set.all %} + {% if perms.agenda_culturel.change_message %} + {% if event.message_set.all.count > 0 %} +

    Cet événement a fait l'objet {% if event.message_set.all.count == 1 %}d'un signalement{% else %}de signalements{% endif %} + {% for cm in event.message_set.all %} le {{ cm.date.date }} à {{ cm.date.time }}{% if not forloop.last %}, {% endif %} {% endfor %}

    @@ -133,9 +134,24 @@ {% include "agenda_culturel/event-date-info-inc.html" %}
    - Exporter ical {% picto_from_name "calendar" %} - {% if perms.agenda_culturel.change_event and not noedit %} - {% include "agenda_culturel/edit-buttons-inc.html" with event=event with_clone=1 %} + {% if onlyedit %} + {% if event.pure_import %} + {% with event.get_local_version as local %} + {% if local %} + voir la version locale {% picto_from_name "eye" %} + {% else %} + créer une copie locale {% picto_from_name "plus-circle" %} + {% endif %} + {% endwith %} + {% else %} + modifier {% picto_from_name "edit-3" %} + {% endif %} + + {% else %} + Exporter ical {% picto_from_name "calendar" %} + {% if perms.agenda_culturel.change_event and not noedit %} + {% include "agenda_culturel/edit-buttons-inc.html" with event=event with_clone=1 %} + {% endif %} {% endif %}
    diff --git a/src/agenda_culturel/templatetags/cat_extra.py b/src/agenda_culturel/templatetags/cat_extra.py index a73e5ad..3499738 100644 --- a/src/agenda_culturel/templatetags/cat_extra.py +++ b/src/agenda_culturel/templatetags/cat_extra.py @@ -86,12 +86,6 @@ def css_categories(): ) result += "}" - result += "*:hover>." + c["css_class"] + " {" - result += background_color_adjust_color( - adjust_lightness_saturation(c["color"], 0.02, 1.0) - ) - result += "}" - result += "." + c["css_class"] + ".circ-cat, " result += "form ." + c["css_class"] + ", " result += ".selected ." + c["css_class"] + " {" diff --git a/src/agenda_culturel/templatetags/event_extra.py b/src/agenda_culturel/templatetags/event_extra.py index 32bdee7..4dd5be9 100644 --- a/src/agenda_culturel/templatetags/event_extra.py +++ b/src/agenda_culturel/templatetags/event_extra.py @@ -179,4 +179,8 @@ def tw_badge(event): if event.tags and len([t for t in event.tags if t.startswith("TW:")]) > 0: return mark_safe('TW') else: - return "" \ No newline at end of file + return "" + +@register.filter +def get_image_uri(event, request): + return event.get_image_url(request) \ No newline at end of file diff --git a/src/agenda_culturel/templatetags/contactmessages_extra.py b/src/agenda_culturel/templatetags/messages_extra.py similarity index 74% rename from src/agenda_culturel/templatetags/contactmessages_extra.py rename to src/agenda_culturel/templatetags/messages_extra.py index 09ad428..fd5e64c 100644 --- a/src/agenda_culturel/templatetags/contactmessages_extra.py +++ b/src/agenda_culturel/templatetags/messages_extra.py @@ -4,7 +4,7 @@ from django.urls import reverse_lazy from django.template.defaultfilters import pluralize -from agenda_culturel.models import ContactMessage +from agenda_culturel.models import Message from .utils_extra import picto_from_name @@ -12,15 +12,15 @@ register = template.Library() @register.simple_tag -def show_badge_contactmessages(placement="top"): - nb_open = ContactMessage.nb_open_contactmessages() +def show_badge_messages(placement="top"): + nb_open = Message.nb_open_messages() if nb_open != 0: return mark_safe( '//", week_view, name="week_view"), @@ -35,12 +57,13 @@ urlpatterns = [ ), path("event//edit", EventUpdateView.as_view(), name="edit_event"), path("event//moderate", EventModerateView.as_view(), name="moderate_event"), - path("event//moderate-next", EventModerateView.as_view(), name="moderate_event_next"), - path("event//moderate-next/error", error_next_event, name="error_next_event"), + path("event//moderate/after/", EventModerateView.as_view(), name="moderate_event_step"), + path("event//moderate-next", moderate_event_next, name="moderate_event_next"), path("moderate", EventModerateView.as_view(), name="moderate"), + path("moderate///", moderate_from_date, name="moderate_from_date"), path("event//simple-clone/edit", EventUpdateView.as_view(), name="simple_clone_edit"), path("event//clone/edit", EventUpdateView.as_view(), name="clone_edit"), - path("event//message", ContactMessageCreateView.as_view(), name="message_for_event"), + path("event//message", MessageCreateView.as_view(), name="message_for_event"), path("event//update-from-source", update_from_source, name="update_from_source"), path( "event//change-status/", @@ -75,18 +98,18 @@ urlpatterns = [ path("mentions-legales", mentions_legales, name="mentions_legales"), path("a-propos", about, name="about"), path("merci", thank_you, name="thank_you"), - path("contact", ContactMessageCreateView.as_view(), name="contact"), - path("contactmessages", contactmessages, name="contactmessages"), - path("contactmessages/spams/delete", delete_cm_spam, name="delete_cm_spam"), + path("contact", MessageCreateView.as_view(), name="contact"), + path("messages", view_messages, name="messages"), + path("messages/spams/delete", delete_cm_spam, name="delete_cm_spam"), path( - "contactmessage/", - ContactMessageUpdateView.as_view(), - name="contactmessage", + "message/", + MessageUpdateView.as_view(), + name="message", ), path( - "contactmessage//delete", - ContactMessageDeleteView.as_view(), - name="delete_contactmessage", + "message//delete", + MessageDeleteView.as_view(), + name="delete_message", ), path("imports/", imports, name="imports"), path("imports/add", add_import, name="add_import"), @@ -134,7 +157,8 @@ urlpatterns = [ path("500/", internal_server_error, name="internal_server_error"), path("organisme//past", OrganisationDetailViewPast.as_view(), name="view_organisation_past"), - path("organisme/", OrganisationDetailView.as_view(), name="view_organisation"), + path("organisme/", OrganisationDetailView.as_view(), name="view_organisation_shortname"), + path("organisme/-", OrganisationDetailView.as_view(), name="view_organisation"), path("organisme/-/past", OrganisationDetailViewPast.as_view(), name="view_organisation_past_fullname"), path("organisme/-", OrganisationDetailView.as_view(), name="view_organisation_fullname"), path("organisme//edit", OrganisationUpdateView.as_view(), name="edit_organisation"), @@ -178,6 +202,14 @@ urlpatterns = [ re_path(r'^robots\.txt', include('robots.urls')), path("__debug__/", include("debug_toolbar.urls")), path("ckeditor5/", include('django_ckeditor_5.urls')), + path( + "sitemap.xml", + cache_page(86400)(sitemap), + {"sitemaps": sitemaps}, + name="django.contrib.sitemaps.views.sitemap", + ), + path("cache/clear", clear_cache, name="clear_cache"), + ] if settings.DEBUG: diff --git a/src/agenda_culturel/utils.py b/src/agenda_culturel/utils.py new file mode 100644 index 0000000..cf5b303 --- /dev/null +++ b/src/agenda_culturel/utils.py @@ -0,0 +1,114 @@ +from agenda_culturel.models import ReferenceLocation +import re +import unicodedata + + +class PlaceGuesser: + + def __init__(self): + self.__citynames = list(ReferenceLocation.objects.values_list("name__lower__unaccent", "name")) + [("clermont-fd", "Clermont-Ferrand"), ("aurillac", "Aurillac"), ("montlucon", "Montluçon"), ("montferrand", "Clermont-Ferrand")] + self.__citynames = [(x[0].replace("-", " "), x[1]) for x in self.__citynames] + + def __remove_accents(self, input_str): + if input_str is None: + return None + nfkd_form = unicodedata.normalize("NFKD", input_str) + return "".join([c for c in nfkd_form if not unicodedata.combining(c)]) + + + def __guess_is_address(self, part): + toponyms = ["bd", "rue", "avenue", "place", "boulevard", "allee", ] + part = part.strip() + if re.match(r'^[0-9]', part): + return True + + elems = part.split(" ") + return any([self.__remove_accents(e.lower()) in toponyms for e in elems]) + + + def __clean_address(self, addr): + toponyms = ["bd", "rue", "avenue", "place", "boulevard", "allée", "bis", "ter", "ZI"] + for t in toponyms: + addr = re.sub(" " + t + " ", " " + t + " ", addr, flags=re.IGNORECASE) + return addr + + def __guess_city_name(self, part): + part = part.strip().replace(" - ", "-") + if len(part) == 0: + return None + part = self.__remove_accents(part.lower()).replace("-", " ") + match = [x[1] for x in self.__citynames if x[0] == part] + if len(match) > 0: + return match[0] + else: + return None + + def __guess_city_name_postcode(self, part): + with_pc = re.search(r'^(.*)(([0-9][ ]*){5})(.*)$', part) + if with_pc: + p1 = self.__guess_city_name(with_pc.group(1).strip()) + postcode = with_pc.group(2).replace(" ", "") + p2 = self.__guess_city_name(with_pc.group(4).strip()) + return postcode, p2, p1 + else: + return None, self.__guess_city_name(part), None + + def __guess_name_address(self, part): + with_num = re.search(r'^(([^0-9])+)([0-9]+)(.*)', part) + if with_num: + name = with_num.group(1) + return name, part[len(name):] + else: + return "", part + + def guess_address_elements(self, alias): + parts = re.split(r'[,/à]', alias) + parts = [p1 for p1 in [p.strip() for p in parts] if p1 != "" and p1.lower() != "france"] + + name = "" + address = "" + postcode = "" + city = "" + possible_city = "" + + oparts = [] + for part in parts: + p, c, possible_c = self.__guess_city_name_postcode(part) + if not possible_c is None: + possible_city = possible_c + if not c is None and city == "": + city = c + if not p is None and postcode == "": + postcode = p + if p is None and c is None: + oparts.append(part) + + if city == "" and possible_city != "": + city = possible_city + else: + if len(oparts) == 0 and not possible_city != "": + oparts = [possible_city] + + if city == "": + alias_simple = self.__remove_accents(alias.lower()).replace("-", " ") + mc = [x[1] for x in self.__citynames if alias_simple.endswith(" " + x[0])] + if len(mc) == 1: + city = mc[0] + + + + + if len(oparts) > 0: + if not self.__guess_is_address(oparts[0]): + name = oparts[0] + address = ", ".join(oparts[1:]) + else: + name, address = self.__guess_name_address(", ".join(oparts)) + + address = self.__clean_address(address) + + if name == "" and possible_city != "" and possible_city != city: + name = possible_city + + return name, address, postcode, city + diff --git a/src/agenda_culturel/views.py b/src/agenda_culturel/views.py index 59ad273..6474766 100644 --- a/src/agenda_culturel/views.py +++ b/src/agenda_culturel/views.py @@ -1,6 +1,6 @@ from django.shortcuts import render, get_object_or_404 from django.views.generic import ListView, DetailView -from django.views.generic.edit import CreateView, UpdateView, DeleteView +from django.views.generic.edit import CreateView, UpdateView, DeleteView, ModelFormMixin from django.contrib.auth.mixins import ( LoginRequiredMixin, UserPassesTestMixin, @@ -10,6 +10,12 @@ from django import forms from django.http import Http404 from django.contrib.postgres.search import SearchQuery, SearchHeadline from django.utils.safestring import mark_safe +from django.utils.decorators import method_decorator +from honeypot.decorators import check_honeypot +from .utils import PlaceGuesser +import hashlib +from django.core.cache import cache + from django.contrib.gis.geos import Point from django.contrib.gis.measure import D @@ -37,13 +43,14 @@ from .forms import ( EventModerateForm, TagForm, TagRenameForm, - ContactMessageForm, + MessageForm, + MessageEventForm, ) from .filters import ( EventFilter, EventFilterAdmin, - ContactMessagesFilterAdmin, + MessagesFilterAdmin, SimpleSearchEventFilter, SearchEventFilter, DuplicatedEventsFilter, @@ -55,7 +62,7 @@ from .models import ( Category, Tag, StaticContent, - ContactMessage, + Message, BatchImportation, DuplicatedEvents, RecurrentImport, @@ -69,7 +76,7 @@ from django.utils import timezone from django.utils.html import escape from datetime import date, timedelta from django.utils.timezone import datetime -from django.db.models import Q, Subquery, OuterRef, Count, F, Func +from django.db.models import Q, Subquery, OuterRef, Count, F, Func, BooleanField, ExpressionWrapper from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ @@ -293,7 +300,7 @@ def update_from_source(request, pk): if url is None: messages.warning(request, _("The event cannot be updated because the import process is not available for the referenced sources.")) else: - import_events_from_url.delay(url, None, True) + import_events_from_url.delay(url, None, None, True, user_id=request.user.pk if request.user else None) messages.success(request, _("The event update has been queued and will be completed shortly.")) return HttpResponseRedirect(event.get_absolute_url()) @@ -313,6 +320,10 @@ class EventUpdateView( kwargs["is_simple_cloning"] = self.is_simple_cloning return kwargs + def form_valid(self, form): + form.instance.set_processing_user(self.request.user) + return super().form_valid(form) + def get_initial(self): self.is_cloning = "clone" in self.request.path.split('/') self.is_simple_cloning = "simple-clone" in self.request.path.split('/') @@ -328,6 +339,7 @@ class EventUpdateView( obj.save() result["other_versions"] = obj.other_versions result["status"] = Event.STATUS.PUBLISHED + result["cloning"] = True if self.is_simple_cloning: result["other_versions"] = None @@ -349,21 +361,27 @@ class EventModerateView( permission_required = "agenda_culturel.change_event" template_name = "agenda_culturel/event_form_moderate.html" form_class = EventModerateForm - success_message = _("The event has been successfully moderated.") + + def get_success_message(self, cleaned_data): + return mark_safe(_('The event {} has been moderated with success.').format(self.object.get_absolute_url(), self.object.title)) + def is_moderate_next(self): - return "moderate-next" in self.request.path.split('/') + return "after" in self.request.path.split('/') def is_starting_moderation(self): return not "pk" in self.kwargs + + def is_moderation_from_date(self): + return "m" in self.kwargs and "y" in self.kwargs and "d" in self.kwargs - def get_next_event(self, start_day, start_time): + def get_next_event(start_day, start_time): # select non moderated events qs = Event.objects.filter(moderated_date__isnull=True) # select events after the current one if start_time: - qs = qs.filter(Q(start_day__gt=start_day)|(Q(start_day=start_day) & (Q(start_time__isnull=True)|Q(start_time__gte=start_time)))) + qs = qs.filter(Q(start_day__gt=start_day)|(Q(start_day=start_day) & (Q(start_time__isnull=True)|Q(start_time__gt=start_time)))) else: qs = qs.filter(start_day__gte=start_day) @@ -384,19 +402,15 @@ class EventModerateView( def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) if self.is_moderate_next(): - context['pred'] = self.kwargs["pk"] + context['pred'] = self.kwargs["pred"] return context def get_object(self, queryset=None): if self.is_starting_moderation(): now = datetime.now() - return self.get_next_event(now.date(), now.time()) + return EventModerateView.get_next_event(now.date(), now.time()) else: - result = super().get_object(queryset) - if self.is_moderate_next(): - return self.get_next_event(result.start_day, result.start_time) - else: - return result + return super().get_object(queryset) def post(self, request, *args, **kwargs): try: @@ -408,15 +422,12 @@ class EventModerateView( def form_valid(self, form): form.instance.set_no_modification_date_changed() form.instance.set_in_moderation_process() + form.instance.set_processing_user(self.request.user) return super().form_valid(form) def get_success_url(self): if 'save_and_next' in self.request.POST: return reverse_lazy("moderate_event_next", args=[self.object.pk]) - elif 'save_and_create_local' in self.request.POST: - return reverse_lazy("clone_edit", args=[self.object.pk]) - elif 'save_and_edit' in self.request.POST: - return reverse_lazy("edit_event", args=[self.object.pk]) elif 'save_and_edit_local' in self.request.POST: return reverse_lazy("edit_event", args=[self.object.get_local_version().pk]) else: @@ -435,6 +446,30 @@ def error_next_event(request, pk): {"pk": pk, "object": obj}, ) +@login_required(login_url="/accounts/login/") +@permission_required("agenda_culturel.change_event") +def moderate_event_next(request, pk): + # current event + obj = Event.objects.filter(pk=pk).first() + start_day = obj.start_day + start_time = obj.start_time + + next_obj = EventModerateView.get_next_event(start_day, start_time) + if next_obj is None: + return render( + request, + "agenda_culturel/event_next_error_message.html", + {"pk": pk, "object": obj}, + ) + else: + return HttpResponseRedirect(reverse_lazy("moderate_event_step", args=[next_obj.pk, obj.pk])) + +@login_required(login_url="/accounts/login/") +@permission_required("agenda_culturel.change_event") +def moderate_from_date(request, y, m, d): + d = date(y, m, d) + obj = EventModerateView.get_next_event(d, None) + return HttpResponseRedirect(reverse_lazy("moderate_event", args=[obj.pk])) class EventDeleteView( @@ -446,9 +481,12 @@ class EventDeleteView( success_message = _("The event has been successfully deleted.") -class EventDetailView(UserPassesTestMixin, DetailView): +class EventDetailView(UserPassesTestMixin, DetailView, ModelFormMixin): model = Event + form_class = MessageEventForm template_name = "agenda_culturel/page-event.html" + queryset = Event.objects.select_related("exact_location").select_related("category").select_related("other_versions").select_related("other_versions__representative").prefetch_related("message_set") + def test_func(self): return ( @@ -458,6 +496,8 @@ class EventDetailView(UserPassesTestMixin, DetailView): def get_object(self): o = super().get_object() + logger.warning(">>>> details") + o.download_missing_image() y = self.kwargs["year"] m = self.kwargs["month"] d = self.kwargs["day"] @@ -465,6 +505,30 @@ class EventDetailView(UserPassesTestMixin, DetailView): obj.set_current_date(date(y, m, d)) return obj + def get_success_url(self): + return self.get_object().get_absolute_url() + "#chronology" + + def post(self, request, *args, **kwargs): + if not request.user.is_authenticated: + return HttpResponseForbidden() + form = self.get_form() + if form.is_valid(): + return self.form_valid(form) + else: + return self.form_invalid(form) + + def form_valid(self, form): + message = form.save(commit=False) + message.user = self.request.user + message.related_event = self.get_object() + message.subject = _("Comment") + message.spam = False + message.closed = True + message.save() + + + return super().form_valid(form) + @login_required(login_url="/accounts/login/") @@ -518,31 +582,16 @@ class EventCreateView(SuccessMessageMixin, CreateView): if form.cleaned_data['simple_cloning']: form.instance.set_skip_duplicate_check() + + if form.cleaned_data['cloning']: + form.instance.set_in_moderation_process() form.instance.import_sources = None + form.instance.set_processing_user(self.request.user) return super().form_valid(form) -def import_from_details(request): - form = EventForm(request.POST, is_authenticated=request.user.is_authenticated) - if form.is_valid(): - new_event = form.save() - if request.user.is_authenticated: - messages.success(request, _("The event is saved.")) - return HttpResponseRedirect(new_event.get_absolute_url()) - else: - messages.success( - request, - _( - "The event has been submitted and will be published as soon as it has been validated by the moderation team." - ), - ) - return HttpResponseRedirect(reverse("home")) - else: - return render( - request, "agenda_culturel/event_form.html", context={"form": form} - ) # A class to evaluate the URL according to the existing events and the authentification # level of the user @@ -640,7 +689,7 @@ def import_from_urls(request): request, _('Integrating {} url(s) into our import process.').format(len(ucat)) ) - import_events_from_urls.delay(ucat) + import_events_from_urls.delay(ucat, user_id=request.user.pk if request.user else None) return HttpResponseRedirect(reverse("thank_you")) else: return HttpResponseRedirect(reverse("home")) @@ -690,7 +739,7 @@ def import_from_url(request): request, _('Integrating {} into our import process.').format(uc.url) ) - import_events_from_url.delay(uc.url, uc.cat, uc.tags) + import_events_from_url.delay(uc.url, uc.cat, uc.tags, user_id=request.user.pk if request.user else None) return HttpResponseRedirect(reverse("thank_you")) @@ -708,7 +757,7 @@ def export_event_ical(request, year, month, day, pk): events = list() events.append(event) - cal = Event.export_to_ics(events) + cal = Event.export_to_ics(events, request) response = HttpResponse(content_type="text/calendar") response.content = cal.to_ical().decode("utf-8").replace("\r\n", "\n") @@ -718,14 +767,17 @@ def export_event_ical(request, year, month, day, pk): return response - def export_ical(request): now = date.today() request = EventFilter.set_default_values(request) filter = EventFilter(request.GET, queryset=get_event_qs(request), request=request) - calendar = CalendarList(now + timedelta(days=-7), now + timedelta(days=+60), filter) - ical = calendar.export_to_ics() + id_cache = hashlib.md5(filter.get_url().encode("utf8")).hexdigest() + ical = cache.get(id_cache) + if not ical: + calendar = CalendarList(now + timedelta(days=-7), now + timedelta(days=+60), filter) + ical = calendar.export_to_ics(request) + cache.set(id_cache, ical, 3600) # 1 heure response = HttpResponse(content_type="text/calendar") response.content = ical.to_ical().decode("utf-8").replace("\r\n", "\n") @@ -736,15 +788,19 @@ def export_ical(request): return response - -class ContactMessageCreateView(SuccessMessageMixin, CreateView): - model = ContactMessage - template_name = "agenda_culturel/contactmessage_create_form.html" - form_class = ContactMessageForm +@method_decorator(check_honeypot, name='post') +class MessageCreateView(SuccessMessageMixin, CreateView): + model = Message + template_name = "agenda_culturel/message_create_form.html" + form_class = MessageForm success_url = reverse_lazy("home") success_message = _("Your message has been sent successfully.") + def __init__(self, *args, **kwargs): + self.event = None + super().__init__(*args, **kwargs) + def get_form(self, form_class=None): if form_class is None: form_class = self.get_form_class() @@ -753,39 +809,48 @@ class ContactMessageCreateView(SuccessMessageMixin, CreateView): def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs["event"] = self.event + if self.request.user.is_authenticated: + kwargs["internal"] = True return kwargs + def form_valid(self, form): + form.instance.user = self.request.user + return super().form_valid(form) + + def get_initial(self): result = super().get_initial() if "pk" in self.kwargs: self.event = get_object_or_404(Event, pk=self.kwargs["pk"]) result["related_event"] = self.event result["subject"] = _('Reporting the event {} on {}').format(self.event.title, self.event.start_day) + else: + result["related_event"] = None return result -class ContactMessageDeleteView(SuccessMessageMixin, DeleteView): - model = ContactMessage +class MessageDeleteView(SuccessMessageMixin, DeleteView): + model = Message success_message = _( "The contact message has been successfully deleted." ) - success_url = reverse_lazy("contactmessages") + success_url = reverse_lazy("messages") -class ContactMessageUpdateView( +class MessageUpdateView( SuccessMessageMixin, PermissionRequiredMixin, LoginRequiredMixin, UpdateView ): - model = ContactMessage - permission_required = "agenda_culturel.change_contactmessage" - template_name = "agenda_culturel/contactmessage_moderation_form.html" + model = Message + permission_required = "agenda_culturel.change_message" + template_name = "agenda_culturel/message_moderation_form.html" fields = ("spam", "closed", "comments") success_message = _( "The contact message properties has been successfully modified." ) - success_url = reverse_lazy("contactmessages") + success_url = reverse_lazy("messages") def get_form_kwargs(self): """Return the keyword arguments for instantiating the form.""" @@ -822,23 +887,27 @@ def activite(request): @login_required(login_url="/accounts/login/") @permission_required("agenda_culturel.view_event") def administration(request): + nb_mod_days = 21 + nb_classes = 4 + today = date.today() + start_time = datetime.now().time() # get information about recent modifications - days = [date.today()] + days = [today] for i in range(0, 2): days.append(days[-1] + timedelta(days=-1)) daily_modifications = Event.get_count_modifications([(d, 1) for d in days]) # get last created events - events = Event.objects.all().order_by("-created_date")[:5] + events = Event.objects.all().order_by("-created_date").select_related("exact_location", "category")[:5] # get last batch imports - batch_imports = BatchImportation.objects.all().order_by("-created_date")[:5] + batch_imports = BatchImportation.objects.all().select_related("recurrentImport").order_by("-created_date")[:5] # get info about batch information newest = BatchImportation.objects.filter(recurrentImport=OuterRef("pk")).order_by( "-created_date" - ) + ).select_related("recurrentImport") imported_events = RecurrentImport.objects.annotate( last_run_status=Subquery(newest.values("status")[:1]) ) @@ -854,13 +923,41 @@ def administration(request): .count()) nb_all = imported_events.count() + window_end = today + timedelta(days=nb_mod_days) + # get all non moderated events + nb_not_moderated = Event.objects.filter(~Q(status=Event.STATUS.TRASH)). \ + filter(Q(start_day__gte=today)&Q(start_day__lte=window_end)). \ + filter( + Q(other_versions__isnull=True) | + Q(other_versions__representative=F('pk')) | + Q(other_versions__representative__isnull=True)).values("start_day").\ + annotate(not_moderated=Count("start_day", filter=Q(moderated_date__isnull=True))). \ + annotate(nb_events=Count("start_day")). \ + order_by("start_day").values("not_moderated", "nb_events", "start_day") + + max_not_moderated = max([x["not_moderated"] for x in nb_not_moderated]) + if max_not_moderated == 0: + max_not_moderated = 1 + nb_not_moderated_dict = dict([(x["start_day"], (x["not_moderated"], x["nb_events"])) for x in nb_not_moderated]) + # add missing dates + date_list = [today + timedelta(days=x) for x in range(0, nb_mod_days)] + nb_not_moderated = [{"start_day": d, + "is_today": d == today, + "nb_events": nb_not_moderated_dict[d][1] if d in nb_not_moderated_dict else 0, + "not_moderated": nb_not_moderated_dict[d][0] if d in nb_not_moderated_dict else 0} for d in date_list] + nb_not_moderated = [ x | { "note": 0 if x["not_moderated"] == 0 else int((nb_classes - 1) * x["not_moderated"] / max_not_moderated) + 1 } for x in nb_not_moderated] + nb_not_moderated = [nb_not_moderated[x:x + 7] for x in range(0, len(nb_not_moderated), 7)] + + + return render( request, "agenda_culturel/administration.html", {"daily_modifications": daily_modifications, "events": events, "batch_imports": batch_imports, "nb_failed": nb_failed, "nb_canceled": nb_canceled, - "nb_running": nb_running, "nb_all": nb_all}, + "nb_running": nb_running, "nb_all": nb_all, + "nb_not_moderated": nb_not_moderated}, ) @@ -889,15 +986,15 @@ def recent(request): @login_required(login_url="/accounts/login/") -@permission_required("agenda_culturel.view_contactmessage") -def contactmessages(request): - filter = ContactMessagesFilterAdmin( - request.GET, queryset=ContactMessage.objects.all().order_by("-date") +@permission_required("agenda_culturel.view_message") +def view_messages(request): + filter = MessagesFilterAdmin( + request.GET, queryset=Message.objects.all().order_by("-date") ) paginator = PaginatorFilter(filter, 10, request) page = request.GET.get("page") - nb_spams = ContactMessage.objects.filter(spam=True).count() + nb_spams = Message.objects.filter(spam=True).count() try: response = paginator.page(page) @@ -908,24 +1005,24 @@ def contactmessages(request): return render( request, - "agenda_culturel/contactmessages.html", + "agenda_culturel/messages.html", {"filter": filter, "nb_spams": nb_spams, "paginator_filter": response}, ) @login_required(login_url="/accounts/login/") -@permission_required("agenda_culturel.view_contactmessage") +@permission_required("agenda_culturel.view_message") def delete_cm_spam(request): if request.method == "POST": - ContactMessage.objects.filter(spam=True).delete() + Message.objects.filter(spam=True).delete() messages.success(request, _("Spam has been successfully deleted.")) - return HttpResponseRedirect(reverse_lazy("contactmessages")) + return HttpResponseRedirect(reverse_lazy("messages")) else: - nb_msgs = ContactMessage.objects.values('spam').annotate(total=Count('spam')) + nb_msgs = Message.objects.values('spam').annotate(total=Count('spam')) nb_total = sum([nb["total"] for nb in nb_msgs]) nb_spams = sum([nb["total"] for nb in nb_msgs if nb["spam"]]) - cancel_url = reverse_lazy("contactmessages") + cancel_url = reverse_lazy("messages") return render( request, "agenda_culturel/delete_spams_confirm.html", @@ -1481,6 +1578,7 @@ def set_duplicate(request, year, month, day, pk): event.other_versions is None or event.other_versions != e.other_versions ) + and e.status != Event.STATUS.TRASH ] form = SelectEventInList(events=others) @@ -1856,6 +1954,7 @@ class UnknownPlaceAddView(PermissionRequiredMixin, SuccessMessageMixin, UpdateVi class PlaceFromEventCreateView(PlaceCreateView): + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["event"] = self.event @@ -1866,6 +1965,14 @@ class PlaceFromEventCreateView(PlaceCreateView): self.event = get_object_or_404(Event, pk=self.kwargs["pk"]) if self.event.location and "add" in self.request.GET: initial["aliases"] = [self.event.location] + guesser = PlaceGuesser() + name, address, postcode, city = guesser.guess_address_elements(self.event.location) + initial["name"] = name + initial["address"] = address + initial["postcode"] = postcode + initial["city"] = city + initial["location"] = "" + return initial def form_valid(self, form): @@ -2125,3 +2232,15 @@ def delete_tag(request, t): "agenda_culturel/tag_confirm_delete_by_name.html", {"tag": t, "nb": nb, "nbi": nbi, "cancel_url": cancel_url, "obj": obj}, ) + + +def clear_cache(request): + if request.method == "POST": + cache.clear() + messages.success(request, _("Cache successfully cleared.")) + return HttpResponseRedirect(reverse_lazy("administration")) + else: + return render( + request, + "agenda_culturel/clear_cache.html", + ) \ No newline at end of file diff --git a/src/requirements.txt b/src/requirements.txt index 4f71636..a0989ee 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -42,4 +42,5 @@ django-location-field==2.7.3 django-robots==6.1 django-debug-toolbar==4.4.6 django-cache-cleaner==0.1.0 -emoji==2.14.0 \ No newline at end of file +emoji==2.14.0 +django-honeypot==1.2.1 \ No newline at end of file diff --git a/src/scripts/set_spam_all_contactmessages.py b/src/scripts/set_spam_all_contactmessages.py index 3dd6671..6eaf7ae 100644 --- a/src/scripts/set_spam_all_contactmessages.py +++ b/src/scripts/set_spam_all_contactmessages.py @@ -1,5 +1,5 @@ -from agenda_culturel.models import ContactMessage +from agenda_culturel.models import Message def run(): - ContactMessage.objects.all().update(spam=True) + Message.objects.all().update(spam=True)