* Ajout de l'import récurrent (manque la partie cron)

* Correction des textes en français. Fix #73
This commit is contained in:
Jean-Marie Favreau 2024-01-26 13:35:47 +01:00
parent 2bac3f29b5
commit 59c091a2f5
27 changed files with 1063 additions and 477 deletions

View File

@ -1,249 +1,29 @@
#!/usr/bin/python3 #!/usr/bin/python3
# coding: utf-8 # coding: utf-8
from abc import ABC, abstractmethod
from urllib.parse import urlparse
import urllib.request
import os import os
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
import icalendar
from icalendar import vDatetime
from datetime import datetime, date
import json import json
from bs4 import BeautifulSoup import sys
import pickle
# 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)
class Downloader(ABC): from src.agenda_culturel.import_tasks.downloader import *
from src.agenda_culturel.import_tasks.extractor import *
def __init__(self): from src.agenda_culturel.import_tasks.importer import *
pass from src.agenda_culturel.import_tasks.extractor_ical import *
@abstractmethod
def download(self, url):
pass
class SimpleDownloader(Downloader):
def __init__(self):
super().__init__()
def download(self, url):
print("Downloading {}".format(url))
try:
resource = urllib.request.urlopen(url)
data = resource.read().decode(resource.headers.get_content_charset())
return data
except:
return None
class ChromiumHeadlessDownloader(Downloader):
def __init__(self):
super().__init__()
options = Options()
options.add_argument("--headless=new")
service = Service("/usr/bin/chromedriver")
self.driver = webdriver.Chrome(service=service, options=options)
def download(self, url):
print("Download {}".format(url))
self.driver.get(url)
return driver.page_source
class Extractor(ABC):
def __init__(self):
self.header = {}
self.events = []
@abstractmethod
def extract(self, content, url, url_human = None):
pass
def set_header(self, url):
self.header["url"] = url
self.header["date"] = datetime.now()
def clear_events(self):
self.events = []
def add_event(self, title, category, start_day, location, description, tags, uuid, recurrences=None, url_human=None, start_time=None, end_day=None, end_time=None, last_modified=None, published=False):
if title is None:
print("ERROR: cannot import an event without name")
return
if start_day is None:
print("ERROR: cannot import an event without start day")
return
event = {
"title": title,
"category": category,
"start_day": start_day,
"uuid": uuid,
"location": location,
"description": description,
"tags": tags,
"published": published
}
if url_human is not None:
event["url_human"] = url_human
if start_time is not None:
event["start_time"] = start_time
if end_day is not None:
event["end_day"] = end_day
if end_time is not None:
event["end_time"] = end_time
if last_modified is not None:
event["last_modified"] = last_modified
if recurrences is not None:
event["recurrences"] = recurrences
self.events.append(event)
def default_value_if_exists(self, default_values, key):
return default_values[key] if default_values is not None and key in default_values else None
def get_structure(self):
return { "header": self.header, "events": self.events}
class ICALExtractor(Extractor):
def __init__(self):
super().__init__()
def get_item_from_vevent(self, event, name, raw = False):
try:
r = event.decoded(name)
if raw:
return r
else:
return r.decode()
except:
return None
def get_dt_item_from_vevent(self, event, name):
item = self.get_item_from_vevent(event, name, raw = True)
day = None
time = None
if item is not None:
if isinstance(item, datetime):
day = item.date()
time = item.time()
elif isinstance(item, date):
day = item
time = None
return day, time
def extract(self, content, url, url_human = None, default_values = None, published = False):
print("Extracting ical events from {}".format(url))
self.set_header(url)
self.clear_events()
self.uuids = {}
calendar = icalendar.Calendar.from_ical(content)
for event in calendar.walk('VEVENT'):
title = self.get_item_from_vevent(event, "SUMMARY")
category = self.default_value_if_exists(default_values, "category")
start_day, start_time = self.get_dt_item_from_vevent(event, "DTSTART")
end_day, end_time = self.get_dt_item_from_vevent(event, "DTEND")
location = self.get_item_from_vevent(event, "LOCATION")
if location is None:
location = self.default_value_if_exists(default_values, "location")
description = self.get_item_from_vevent(event, "DESCRIPTION")
if description is not None:
soup = BeautifulSoup(description)
delimiter = '\n'
for line_break in soup.findAll('br'):
line_break.replaceWith(delimiter)
description = soup.get_text()
last_modified = self.get_item_from_vevent(event, "LAST_MODIFIED")
uuid = self.get_item_from_vevent(event, "UID")
if uuid is not None:
if uuid in self.uuids:
self.uuids[uuid] += 1
uuid += ":{:04}".format(self.uuids[uuid] - 1)
else:
self.uuids[uuid] = 1
event_url = url + "#" + uuid
tags = self.default_value_if_exists(default_values, "tags")
last_modified = self.get_item_from_vevent(event, "LAST-MODIFIED", raw = True)
recurrence_entries = {}
for e in ["RRULE", "EXRULE", "EXDATE", "RDATE"]:
i = self.get_item_from_vevent(event, e, raw = True)
if i is not None:
recurrence_entries[e] = i
if start_day is not None and len(recurrence_entries) != 0:
recurrences = ""
for k, r in recurrence_entries.items():
if isinstance(r, list):
recurrences += "\n".join([k + ":" + e.to_ical().decode() for e in r]) + "\n"
else:
recurrences += k + ":" + r.to_ical().decode() + "\n"
else:
recurrences = None
self.add_event(title, category, start_day, location, description, tags, recurrences=recurrences, uuid=event_url, url_human=url_human, start_time=start_time, end_day=end_day, end_time=end_time, last_modified=last_modified, published=published)
return self.get_structure()
class URL2Events:
def __init__(self, downloader, extractor):
self.downloader = downloader
self.extractor = extractor
def process(self, url, url_human = None, cache = None, default_values = None, published = False):
if cache and os.path.exists(cache):
print("Loading cache ({})".format(cache))
with open(cache) as f:
content = "\n".join(f.readlines())
else:
content = self.downloader.download(url)
if cache:
print("Saving cache ({})".format(cache))
dir = os.path.dirname(cache)
if dir != "" and not os.path.exists(dir):
os.makedirs(dir)
with open(cache, "w") as text_file:
text_file.write(content)
return self.extractor.extract(content, url, url_human, default_values, published)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from django import forms from django import forms
from .models import Event, Category, StaticContent, DuplicatedEvents, BatchImportation from .models import Event, Category, StaticContent, DuplicatedEvents, BatchImportation, RecurrentImport
from django_better_admin_arrayfield.admin.mixins import DynamicArrayMixin from django_better_admin_arrayfield.admin.mixins import DynamicArrayMixin
from django_better_admin_arrayfield.forms.widgets import DynamicArrayWidget from django_better_admin_arrayfield.forms.widgets import DynamicArrayWidget
from django_better_admin_arrayfield.models.fields import DynamicArrayField from django_better_admin_arrayfield.models.fields import DynamicArrayField
@ -10,6 +10,7 @@ admin.site.register(Category)
admin.site.register(StaticContent) admin.site.register(StaticContent)
admin.site.register(DuplicatedEvents) admin.site.register(DuplicatedEvents)
admin.site.register(BatchImportation) admin.site.register(BatchImportation)
admin.site.register(RecurrentImport)
class URLWidget(DynamicArrayWidget): class URLWidget(DynamicArrayWidget):

View File

@ -1,4 +1,5 @@
import os import os
import json
from celery import Celery from celery import Celery
from celery.schedules import crontab from celery.schedules import crontab
@ -6,6 +7,13 @@ from celery.utils.log import get_task_logger
from .extractors import ExtractorAllURLs from .extractors import ExtractorAllURLs
from .import_tasks.downloader import *
from .import_tasks.extractor import *
from .import_tasks.importer import *
from .import_tasks.extractor_ical import *
# Set the default Django settings module for the 'celery' program. # Set the default Django settings module for the 'celery' program.
APP_ENV = os.getenv("APP_ENV", "dev") APP_ENV = os.getenv("APP_ENV", "dev")
@ -44,14 +52,20 @@ def close_import_task(taskid, success, error_message, importer):
@app.task(bind=True) @app.task(bind=True)
def import_events_from_json(self, json): def import_events_from_json(self, json):
from agenda_culturel.models import Event from agenda_culturel.models import Event, BatchImportation
from .importation import EventsImporter from .importation import EventsImporter
# create a batch importation
importation = BatchImportation(celery_id=self.request.id)
# save batch importation
importation.save()
logger.info("Import events from json: {}".format(self.request.id)) logger.info("Import events from json: {}".format(self.request.id))
importer = EventsImporter(self.request.id) importer = EventsImporter(self.request.id)
# try: #try:
success, error_message = importer.import_events(json) success, error_message = importer.import_events(json)
# finally, close task # finally, close task
@ -62,25 +76,55 @@ def import_events_from_json(self, json):
@app.task(bind=True) @app.task(bind=True)
def import_events_from_url(self, source, browsable_url): def run_recurrent_import(self, pk):
from agenda_culturel.models import Event from agenda_culturel.models import RecurrentImport, BatchImportation
from .importation import EventsImporter from .importation import EventsImporter
from django.shortcuts import get_object_or_404
logger.info("Import events from url: {} {}".format(source, self.request.id)) logger.info("Run recurrent import: {}".format(self.request.id))
# first get json
# TODO
# then import # get the recurrent import
# importer = EventsImporter(self.request.id) rimport = RecurrentImport.objects.get(pk=pk)
# success, error_message = importer.import_events(json)
# create a batch importation
importation = BatchImportation(recurrentImport=rimport, celery_id=self.request.id)
# save batch importation
importation.save()
success = True # create an importer
error_message = "" importer = EventsImporter(self.request.id)
# finally, close task # prepare downloading and extracting processes
close_import_task(self.request.id, success, error_message) downloader = SimpleDownloader() if rimport.downloader == RecurrentImport.DOWNLOADER.SIMPLE else ChromiumHeadlessDownloader()
extractor = ICALExtractor() if rimport.processor == RecurrentImport.PROCESSOR.ICAL else None
if extractor is None:
logger.error("Unknown extractor")
close_import_task(self.request.id, False, "Unknown extractor", importer)
# set parameters
u2e = URL2Events(downloader, extractor)
url = rimport.source
browsable_url = rimport.browsable_url
category = str(rimport.defaultCategory)
location = rimport.defaultLocation
tags = rimport.defaultTags
try:
# get events from website
events = u2e.process(url, browsable_url, default_values = {"category": category, "location": location, "tags": tags}, published = True)
# convert it to json
json_events = json.dumps(events, default=str)
# import events (from json)
success, error_message = importer.import_events(json_events)
# finally, close task
close_import_task(self.request.id, success, error_message, importer)
except Exception as e:
logger.error(e)
close_import_task(self.request.id, False, e, importer)

View File

@ -2,7 +2,7 @@ from django.forms import ModelForm, ValidationError, TextInput, Form, URLField,
from datetime import date from datetime import date
from django_better_admin_arrayfield.forms.widgets import DynamicArrayWidget from django_better_admin_arrayfield.forms.widgets import DynamicArrayWidget
from .models import Event, BatchImportation from .models import Event, BatchImportation, RecurrentImport
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from string import ascii_uppercase as auc from string import ascii_uppercase as auc
from .templatetags.utils_extra import int_to_abc from .templatetags.utils_extra import int_to_abc
@ -25,6 +25,11 @@ class DynamicArrayWidgetURLs(DynamicArrayWidget):
class DynamicArrayWidgetTags(DynamicArrayWidget): class DynamicArrayWidgetTags(DynamicArrayWidget):
template_name = "agenda_culturel/widgets/widget-tags.html" template_name = "agenda_culturel/widgets/widget-tags.html"
class RecurrentImportForm(ModelForm):
class Meta:
model = RecurrentImport
fields = '__all__'
class EventForm(ModelForm): class EventForm(ModelForm):
class Meta: class Meta:
@ -75,26 +80,8 @@ class EventForm(ModelForm):
class BatchImportationForm(ModelForm): class BatchImportationForm(Form):
json = CharField(label="JSON", widget=Textarea(attrs={"rows":"10"}), help_text=_("JSON in the format expected for the import."), required=True)
json = CharField(label="JSON (facultatif)", widget=Textarea(attrs={"rows":"10"}), help_text=_("JSON in the format expected for the import. If the JSON is provided here, we will ignore the URLs given above, and use the information provided by the json without importing any additional events from the URL."), required=False)
class Meta:
model = BatchImportation
fields = ['source', 'browsable_url']
def clean(self):
cleaned_data = super().clean()
json = cleaned_data.get("json")
source = cleaned_data.get("source")
browsable_url = cleaned_data.get("browsable_url")
if (not json or json == "") and (not source or source == "") and (not browsable_url or browsable_url == ""):
raise ValidationError(_("You need to fill in either the json or the source possibly supplemented by the navigable URL."))
# Always return a value to use as the new cleaned data, even if
# this method didn't change it.
return cleaned_data
class FixDuplicates(Form): class FixDuplicates(Form):

View File

@ -0,0 +1,50 @@
from urllib.parse import urlparse
import urllib.request
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from abc import ABC, abstractmethod
class Downloader(ABC):
def __init__(self):
pass
@abstractmethod
def download(self, url):
pass
class SimpleDownloader(Downloader):
def __init__(self):
super().__init__()
def download(self, url):
print("Downloading {}".format(url))
try:
resource = urllib.request.urlopen(url)
data = resource.read().decode(resource.headers.get_content_charset())
return data
except:
return None
class ChromiumHeadlessDownloader(Downloader):
def __init__(self):
super().__init__()
options = Options()
options.add_argument("--headless=new")
service = Service("/usr/bin/chromedriver")
self.driver = webdriver.Chrome(service=service, options=options)
def download(self, url):
print("Download {}".format(url))
self.driver.get(url)
return driver.page_source

View File

@ -0,0 +1,62 @@
from abc import ABC, abstractmethod
from bs4 import BeautifulSoup
from datetime import datetime
class Extractor(ABC):
def __init__(self):
self.header = {}
self.events = []
@abstractmethod
def extract(self, content, url, url_human = None):
pass
def set_header(self, url):
self.header["url"] = url
self.header["date"] = datetime.now()
def clear_events(self):
self.events = []
def add_event(self, title, category, start_day, location, description, tags, uuid, recurrences=None, url_human=None, start_time=None, end_day=None, end_time=None, last_modified=None, published=False):
if title is None:
print("ERROR: cannot import an event without name")
return
if start_day is None:
print("ERROR: cannot import an event without start day")
return
event = {
"title": title,
"category": category,
"start_day": start_day,
"uuid": uuid,
"location": location,
"description": description,
"tags": tags,
"published": published
}
if url_human is not None:
event["url_human"] = url_human
if start_time is not None:
event["start_time"] = start_time
if end_day is not None:
event["end_day"] = end_day
if end_time is not None:
event["end_time"] = end_time
if last_modified is not None:
event["last_modified"] = last_modified
if recurrences is not None:
event["recurrences"] = recurrences
self.events.append(event)
def default_value_if_exists(self, default_values, key):
return default_values[key] if default_values is not None and key in default_values else None
def get_structure(self):
return { "header": self.header, "events": self.events}

View File

@ -0,0 +1,110 @@
import icalendar
import warnings
from icalendar import vDatetime
from datetime import datetime, date
from bs4 import BeautifulSoup, MarkupResemblesLocatorWarning
from .extractor import *
class ICALExtractor(Extractor):
def __init__(self):
super().__init__()
def get_item_from_vevent(self, event, name, raw = False):
try:
r = event.decoded(name)
if raw:
return r
else:
return r.decode()
except:
return None
def get_dt_item_from_vevent(self, event, name):
item = self.get_item_from_vevent(event, name, raw = True)
day = None
time = None
if item is not None:
if isinstance(item, datetime):
day = item.date()
time = item.time()
elif isinstance(item, date):
day = item
time = None
return day, time
def extract(self, content, url, url_human = None, default_values = None, published = False):
warnings.filterwarnings("ignore", category=MarkupResemblesLocatorWarning)
print("Extracting ical events from {}".format(url))
self.set_header(url)
self.clear_events()
self.uuids = {}
calendar = icalendar.Calendar.from_ical(content)
for event in calendar.walk('VEVENT'):
title = self.get_item_from_vevent(event, "SUMMARY")
category = self.default_value_if_exists(default_values, "category")
start_day, start_time = self.get_dt_item_from_vevent(event, "DTSTART")
end_day, end_time = self.get_dt_item_from_vevent(event, "DTEND")
location = self.get_item_from_vevent(event, "LOCATION")
if location is None:
location = self.default_value_if_exists(default_values, "location")
description = self.get_item_from_vevent(event, "DESCRIPTION")
if description is not None:
soup = BeautifulSoup(description, features="lxml")
delimiter = '\n'
for line_break in soup.findAll('br'):
line_break.replaceWith(delimiter)
description = soup.get_text()
last_modified = self.get_item_from_vevent(event, "LAST_MODIFIED")
uuid = self.get_item_from_vevent(event, "UID")
if uuid is not None:
if uuid in self.uuids:
self.uuids[uuid] += 1
uuid += ":{:04}".format(self.uuids[uuid] - 1)
else:
self.uuids[uuid] = 1
event_url = url + "#" + uuid
tags = self.default_value_if_exists(default_values, "tags")
last_modified = self.get_item_from_vevent(event, "LAST-MODIFIED", raw = True)
recurrence_entries = {}
for e in ["RRULE", "EXRULE", "EXDATE", "RDATE"]:
i = self.get_item_from_vevent(event, e, raw = True)
if i is not None:
recurrence_entries[e] = i
if start_day is not None and len(recurrence_entries) != 0:
recurrences = ""
for k, r in recurrence_entries.items():
if isinstance(r, list):
recurrences += "\n".join([k + ":" + e.to_ical().decode() for e in r]) + "\n"
else:
recurrences += k + ":" + r.to_ical().decode() + "\n"
else:
recurrences = None
if title is not None:
self.add_event(title, category, start_day, location, description, tags, recurrences=recurrences, uuid=event_url, url_human=url_human, start_time=start_time, end_day=end_day, end_time=end_time, last_modified=last_modified, published=published)
return self.get_structure()

View File

@ -0,0 +1,31 @@
import os
from .downloader import *
from .extractor import *
class URL2Events:
def __init__(self, downloader, extractor):
self.downloader = downloader
self.extractor = extractor
def process(self, url, url_human = None, cache = None, default_values = None, published = False):
if cache and os.path.exists(cache):
print("Loading cache ({})".format(cache))
with open(cache) as f:
content = "\n".join(f.readlines())
else:
content = self.downloader.download(url)
if cache:
print("Saving cache ({})".format(cache))
dir = os.path.dirname(cache)
if dir != "" and not os.path.exists(dir):
os.makedirs(dir)
with open(cache, "w") as text_file:
text_file.write(content)
return self.extractor.extract(content, url, url_human, default_values, published)

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: agenda_culturel\n" "Project-Id-Version: agenda_culturel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-29 15:35+0000\n" "POT-Creation-Date: 2024-01-26 12:27+0000\n"
"PO-Revision-Date: 2023-10-29 14:16+0000\n" "PO-Revision-Date: 2023-10-29 14:16+0000\n"
"Last-Translator: Jean-Marie Favreau <jeanmarie.favreau@free.fr>\n" "Last-Translator: Jean-Marie Favreau <jeanmarie.favreau@free.fr>\n"
"Language-Team: Jean-Marie Favreau <jeanmarie.favreau@free.fr>\n" "Language-Team: Jean-Marie Favreau <jeanmarie.favreau@free.fr>\n"
@ -17,340 +17,395 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
#: agenda_culturel/forms.py:38 #: agenda_culturel/forms.py:62
msgid "The end date must be after the start date." msgid "The end date must be after the start date."
msgstr "La date de fin doit être après la date de début." msgstr "La date de fin doit être après la date de début."
#: agenda_culturel/forms.py:53 #: agenda_culturel/forms.py:77
msgid "The end time cannot be earlier than the start time." 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." msgstr "L'heure de fin ne peut pas être avant l'heure de début."
#: agenda_culturel/forms.py:61 #: agenda_culturel/forms.py:84
msgid "" msgid "JSON in the format expected for the import."
"JSON in the format expected for the import. If the JSON is provided here, we " msgstr "JSON dans le format attendu pour l'import"
"will ignore the URLs given above, and use the information provided by the "
"json without importing any additional events from the URL."
msgstr ""
"JSON au format attendu pour l'import. Si le JSON est fourni ici, on ignorera "
"les URL données au dessus, et on utilisera les informations fournies par le "
"json sans réaliser d'importation supplémentaire d'événements depuis l'URL."
#: agenda_culturel/forms.py:74 #: agenda_culturel/models.py:32 agenda_culturel/models.py:61
msgid "" #: agenda_culturel/models.py:695
"You need to fill in either the json or the source possibly supplemented by "
"the navigable URL."
msgstr ""
"Vous devez renseigner soit le json soit la source éventuellement complétée "
"de l'URL navigable."
#: agenda_culturel/models.py:26 agenda_culturel/models.py:55
#: agenda_culturel/models.py:465
msgid "Name" msgid "Name"
msgstr "Nom" msgstr "Nom"
#: agenda_culturel/models.py:26 agenda_culturel/models.py:55 #: agenda_culturel/models.py:32 agenda_culturel/models.py:61
msgid "Category name" msgid "Category name"
msgstr "Nom de la catégorie" msgstr "Nom de la catégorie"
#: agenda_culturel/models.py:27 #: agenda_culturel/models.py:33
msgid "Content" msgid "Content"
msgstr "Contenu" msgstr "Contenu"
#: agenda_culturel/models.py:27 #: agenda_culturel/models.py:33
msgid "Text as shown to the visitors" msgid "Text as shown to the visitors"
msgstr "Text tel que présenté aux visiteureuses" msgstr "Text tel que présenté aux visiteureuses"
#: agenda_culturel/models.py:28 #: agenda_culturel/models.py:34
msgid "URL path" msgid "URL path"
msgstr "" msgstr ""
#: agenda_culturel/models.py:28 #: agenda_culturel/models.py:34
msgid "URL path where the content is included." msgid "URL path where the content is included."
msgstr "" msgstr ""
#: agenda_culturel/models.py:56 #: agenda_culturel/models.py:62
msgid "Alternative Name" msgid "Alternative Name"
msgstr "Nom alternatif" msgstr "Nom alternatif"
#: agenda_culturel/models.py:56 #: agenda_culturel/models.py:62
msgid "Alternative name used with a time period" msgid "Alternative name used with a time period"
msgstr "Nom alternatif utilisé avec une période de temps" msgstr "Nom alternatif utilisé avec une période de temps"
#: agenda_culturel/models.py:57 #: agenda_culturel/models.py:63
msgid "Short name" msgid "Short name"
msgstr "Nom court" msgstr "Nom court"
#: agenda_culturel/models.py:57 #: agenda_culturel/models.py:63
msgid "Short name of the category" msgid "Short name of the category"
msgstr "Nom court de la catégorie" msgstr "Nom court de la catégorie"
#: agenda_culturel/models.py:58 #: agenda_culturel/models.py:64
msgid "Color" msgid "Color"
msgstr "Couleur" msgstr "Couleur"
#: agenda_culturel/models.py:58 #: agenda_culturel/models.py:64
msgid "Color used as background for the category" msgid "Color used as background for the category"
msgstr "Couleur utilisée comme fond de la catégorie" msgstr "Couleur utilisée comme fond de la catégorie"
#: agenda_culturel/models.py:95 agenda_culturel/models.py:142 #: agenda_culturel/models.py:101 agenda_culturel/models.py:168
#: agenda_culturel/models.py:733
msgid "Category" msgid "Category"
msgstr "Catégorie" msgstr "Catégorie"
#: agenda_culturel/models.py:96 #: agenda_culturel/models.py:102
msgid "Categories" msgid "Categories"
msgstr "Catégories" msgstr "Catégories"
#: agenda_culturel/models.py:130 #: agenda_culturel/models.py:153
msgid "Published" msgid "Published"
msgstr "Publié" msgstr "Publié"
#: agenda_culturel/models.py:131 #: agenda_culturel/models.py:154
msgid "Draft" msgid "Draft"
msgstr "Brouillon" msgstr "Brouillon"
#: agenda_culturel/models.py:132 #: agenda_culturel/models.py:155
msgid "Trash" msgid "Trash"
msgstr "Corbeille" msgstr "Corbeille"
#: agenda_culturel/models.py:138 #: agenda_culturel/models.py:164
msgid "Title" msgid "Title"
msgstr "Titre" msgstr "Titre"
#: agenda_culturel/models.py:138 #: agenda_culturel/models.py:164
msgid "Short title" msgid "Short title"
msgstr "Titre court" msgstr "Titre court"
#: agenda_culturel/models.py:140 agenda_culturel/models.py:492 #: agenda_culturel/models.py:166 agenda_culturel/models.py:756
msgid "Status" msgid "Status"
msgstr "Status" msgstr "Status"
#: agenda_culturel/models.py:142 #: agenda_culturel/models.py:168
msgid "Category of the event" msgid "Category of the event"
msgstr "Catégorie de l'événement" msgstr "Catégorie de l'événement"
#: agenda_culturel/models.py:144 #: agenda_culturel/models.py:170
msgid "Day of the event" msgid "Day of the event"
msgstr "Date de l'événement" msgstr "Date de l'événement"
#: agenda_culturel/models.py:145 #: agenda_culturel/models.py:171
msgid "Starting time" msgid "Starting time"
msgstr "Heure de début" msgstr "Heure de début"
#: agenda_culturel/models.py:147 #: agenda_culturel/models.py:173
msgid "End day of the event" msgid "End day of the event"
msgstr "Fin de l'événement" msgstr "Fin de l'événement"
#: agenda_culturel/models.py:147 #: agenda_culturel/models.py:173
msgid "End day of the event, only required if different from the start day." msgid "End day of the event, only required if different from the start day."
msgstr "" msgstr ""
"Date de fin de l'événement, uniquement nécessaire s'il est différent du " "Date de fin de l'événement, uniquement nécessaire s'il est différent du "
"premier jour de l'événement" "premier jour de l'événement"
#: agenda_culturel/models.py:148 #: agenda_culturel/models.py:174
msgid "Final time" msgid "Final time"
msgstr "Heure de fin" msgstr "Heure de fin"
#: agenda_culturel/models.py:150 #: agenda_culturel/models.py:176
msgid "Recurrence"
msgstr "Récurrence"
#: agenda_culturel/models.py:178 agenda_culturel/models.py:731
msgid "Location" msgid "Location"
msgstr "Localisation" msgstr "Localisation"
#: agenda_culturel/models.py:150 #: agenda_culturel/models.py:178
msgid "Address of the event" msgid "Address of the event"
msgstr "Adresse de l'événement" msgstr "Adresse de l'événement"
#: agenda_culturel/models.py:152 #: agenda_culturel/models.py:180
msgid "Description" msgid "Description"
msgstr "Description" msgstr "Description"
#: agenda_culturel/models.py:152 #: agenda_culturel/models.py:180
msgid "General description of the event" msgid "General description of the event"
msgstr "Description générale de l'événement" msgstr "Description générale de l'événement"
#: agenda_culturel/models.py:154 #: agenda_culturel/models.py:182
msgid "Illustration (local image)" msgid "Illustration (local image)"
msgstr "Illustration (image locale)" msgstr "Illustration (image locale)"
#: agenda_culturel/models.py:154 #: agenda_culturel/models.py:182
msgid "Illustration image stored in the agenda server" msgid "Illustration image stored in the agenda server"
msgstr "Image d'illustration stockée sur le serveur de l'agenda" msgstr "Image d'illustration stockée sur le serveur de l'agenda"
#: agenda_culturel/models.py:156 #: agenda_culturel/models.py:184
msgid "Illustration" msgid "Illustration"
msgstr "Illustration" msgstr "Illustration"
#: agenda_culturel/models.py:156 #: agenda_culturel/models.py:184
msgid "URL of the illustration image" msgid "URL of the illustration image"
msgstr "URL de l'image illustrative" msgstr "URL de l'image illustrative"
#: agenda_culturel/models.py:157 #: agenda_culturel/models.py:185
msgid "Illustration description" msgid "Illustration description"
msgstr "Description de l'illustration" msgstr "Description de l'illustration"
#: agenda_culturel/models.py:157 #: agenda_culturel/models.py:185
msgid "Alternative text used by screen readers for the image" msgid "Alternative text used by screen readers for the image"
msgstr "Texte alternatif utiliser par les lecteurs d'écrans pour l'image" msgstr "Texte alternatif utiliser par les lecteurs d'écrans pour l'image"
#: agenda_culturel/models.py:159 #: agenda_culturel/models.py:187
msgid "Importation source"
msgstr "Source d'importation"
#: agenda_culturel/models.py:187
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:188
msgid "UUIDs" msgid "UUIDs"
msgstr "UUIDs" msgstr "UUIDs"
#: agenda_culturel/models.py:159 #: agenda_culturel/models.py:188
msgid "UUIDs from import to detect duplicated entries." msgid "UUIDs from import to detect duplicated entries."
msgstr "UUIDs utilisés pendant l'import pour détecter les entrées dupliquées" msgstr "UUIDs utilisés pendant l'import pour détecter les entrées dupliquées"
#: agenda_culturel/models.py:160 #: agenda_culturel/models.py:189
msgid "URLs" msgid "URLs"
msgstr "URLs" msgstr "URLs"
#: agenda_culturel/models.py:160 #: agenda_culturel/models.py:189
msgid "List of all the urls where this event can be found." 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é." msgstr "Liste de toutes les urls où l'événement peut être trouvé."
#: agenda_culturel/models.py:162 #: agenda_culturel/models.py:191
msgid "Tags" msgid "Tags"
msgstr "Étiquettes" msgstr "Étiquettes"
#: agenda_culturel/models.py:162 #: agenda_culturel/models.py:191
msgid "A list of tags that describe the event." msgid "A list of tags that describe the event."
msgstr "Une liste d'étiquettes décrivant l'événement" msgstr "Une liste d'étiquettes décrivant l'événement"
#: agenda_culturel/models.py:164 #: agenda_culturel/models.py:193
msgid "Possibly duplicated" msgid "Possibly duplicated"
msgstr "Possibles doublons" msgstr "Possibles doublons"
#: agenda_culturel/models.py:194 #: agenda_culturel/models.py:234
msgid "Event" msgid "Event"
msgstr "Événement" msgstr "Événement"
#: agenda_culturel/models.py:195 #: agenda_culturel/models.py:235
msgid "Events" msgid "Events"
msgstr "Événements" msgstr "Événements"
#: agenda_culturel/models.py:464 #: agenda_culturel/models.py:694
msgid "Subject" msgid "Subject"
msgstr "Sujet" msgstr "Sujet"
#: agenda_culturel/models.py:464 #: agenda_culturel/models.py:694
msgid "The subject of your message" msgid "The subject of your message"
msgstr "Sujet de votre message" msgstr "Sujet de votre message"
#: agenda_culturel/models.py:465 #: agenda_culturel/models.py:695
msgid "Your name" msgid "Your name"
msgstr "Votre nom" msgstr "Votre nom"
#: agenda_culturel/models.py:466 #: agenda_culturel/models.py:696
msgid "Email address" msgid "Email address"
msgstr "Adresse email" msgstr "Adresse email"
#: agenda_culturel/models.py:466 #: agenda_culturel/models.py:696
msgid "Your email address" msgid "Your email address"
msgstr "Votre adresse email" msgstr "Votre adresse email"
#: agenda_culturel/models.py:467 #: agenda_culturel/models.py:697
msgid "Message" msgid "Message"
msgstr "Message" msgstr "Message"
#: agenda_culturel/models.py:467 #: agenda_culturel/models.py:697
msgid "Your message" msgid "Your message"
msgstr "Votre message" msgstr "Votre message"
#: agenda_culturel/models.py:471 agenda_culturel/views.py:343 #: agenda_culturel/models.py:701 agenda_culturel/views.py:366
msgid "Closed" msgid "Closed"
msgstr "Fermé" msgstr "Fermé"
#: agenda_culturel/models.py:471 #: agenda_culturel/models.py:701
msgid "this message has been processed and no longer needs to be handled" 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" msgstr "Ce message a été traité et ne nécessite plus d'être pris en charge"
#: agenda_culturel/models.py:472 #: agenda_culturel/models.py:702
msgid "Comments" msgid "Comments"
msgstr "Commentaires" msgstr "Commentaires"
#: agenda_culturel/models.py:472 #: agenda_culturel/models.py:702
msgid "Comments on the message from the moderation team" msgid "Comments on the message from the moderation team"
msgstr "Commentaires sur ce message par l'équipe de modération" msgstr "Commentaires sur ce message par l'équipe de modération"
#: agenda_culturel/models.py:481 #: agenda_culturel/models.py:711
msgid "Running" msgid "ical"
msgstr "" msgstr "ical"
#: agenda_culturel/models.py:482 #: agenda_culturel/models.py:714
msgid "Canceled" msgid "simple"
msgstr "Annulé" msgstr "simple"
#: agenda_culturel/models.py:483 #: agenda_culturel/models.py:715
msgid "Success" msgid "Headless Chromium"
msgstr "Succès" msgstr "chromium sans interface"
#: agenda_culturel/models.py:484 #: agenda_culturel/models.py:719
msgid "Failed" msgid "daily"
msgstr "Erreur" msgstr "chaque jour"
#: agenda_culturel/models.py:489 #: agenda_culturel/models.py:720
msgid "weekly"
msgstr "chaque semaine"
#: agenda_culturel/models.py:722
msgid "Processor"
msgstr "Processeur"
#: agenda_culturel/models.py:723
msgid "Downloader"
msgstr "Téléchargeur"
#: agenda_culturel/models.py:725
msgid "Import recurrence"
msgstr "Récurrence d'import"
#: agenda_culturel/models.py:728
msgid "Source" msgid "Source"
msgstr "Source" msgstr "Source"
#: agenda_culturel/models.py:489 #: agenda_culturel/models.py:728
msgid "URL of the source document" msgid "URL of the source document"
msgstr "URL du document source" msgstr "URL du document source"
#: agenda_culturel/models.py:490 #: agenda_culturel/models.py:729
msgid "Browsable url" msgid "Browsable url"
msgstr "URL navigable" msgstr "URL navigable"
#: agenda_culturel/models.py:490 #: agenda_culturel/models.py:729
msgid "URL of the corresponding document that will be shown to visitors." msgid "URL of the corresponding document that will be shown to visitors."
msgstr "URL correspondant au document et qui sera montrée aux visiteurs" msgstr "URL correspondant au document et qui sera montrée aux visiteurs"
#: agenda_culturel/models.py:494 #: agenda_culturel/models.py:731
msgid "Address for each imported event"
msgstr "Adresse de chaque événement importé"
#: agenda_culturel/models.py:733
msgid "Category of each imported event"
msgstr "Catégorie de chaque événement importé"
#: agenda_culturel/models.py:734
msgid "Tags for each imported event"
msgstr "Étiquettes de chaque événement importé"
#: agenda_culturel/models.py:734
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:746
msgid "Running"
msgstr "En cours"
#: agenda_culturel/models.py:747
msgid "Canceled"
msgstr "Annulé"
#: agenda_culturel/models.py:748
msgid "Success"
msgstr "Succès"
#: agenda_culturel/models.py:749
msgid "Failed"
msgstr "Erreur"
#: agenda_culturel/models.py:754
msgid "Recurrent import"
msgstr "Import récurrent"
#: agenda_culturel/models.py:754
msgid "Reference to the recurrent import processing"
msgstr "Référence du processus d'import récurrent"
#: agenda_culturel/models.py:758
msgid "Error message" msgid "Error message"
msgstr "Votre message" msgstr "Votre message"
#: agenda_culturel/models.py:496 #: agenda_culturel/models.py:760
msgid "Number of collected events" msgid "Number of collected events"
msgstr "Nombre d'événements collectés" msgstr "Nombre d'événements collectés"
#: agenda_culturel/models.py:497 #: agenda_culturel/models.py:761
msgid "Number of imported events" msgid "Number of imported events"
msgstr "Nombre d'événements importés" msgstr "Nombre d'événements importés"
#: agenda_culturel/models.py:498 #: agenda_culturel/models.py:762
msgid "Number of updated events" msgid "Number of updated events"
msgstr "Nombre d'événements mis à jour" msgstr "Nombre d'événements mis à jour"
#: agenda_culturel/models.py:499 #: agenda_culturel/models.py:763
msgid "Number of removed events" msgid "Number of removed events"
msgstr "Nombre d'événements supprimés" msgstr "Nombre d'événements supprimés"
#: agenda_culturel/settings/base.py:134 #: agenda_culturel/settings/base.py:135
msgid "English" msgid "English"
msgstr "anglais" msgstr "anglais"
#: agenda_culturel/settings/base.py:135 #: agenda_culturel/settings/base.py:136
msgid "French" msgid "French"
msgstr "français" msgstr "français"
#: agenda_culturel/views.py:190 #: agenda_culturel/views.py:203
msgid "The static content has been successfully updated." msgid "The static content has been successfully updated."
msgstr "Le contenu statique a été modifié avec succès." msgstr "Le contenu statique a été modifié avec succès."
#: agenda_culturel/views.py:196 #: agenda_culturel/views.py:209
msgid "The event has been successfully modified." msgid "The event has been successfully modified."
msgstr "L'événement a été modifié avec succès." msgstr "L'événement a été modifié avec succès."
#: agenda_culturel/views.py:207 #: agenda_culturel/views.py:220
msgid "The event has been successfully deleted." msgid "The event has been successfully deleted."
msgstr "L'événement a été supprimé avec succès" msgstr "L'événement a été supprimé avec succès"
#: agenda_culturel/views.py:224 #: agenda_culturel/views.py:247
msgid "The status has been successfully modified." msgid "The status has been successfully modified."
msgstr "Le status a été modifié avec succès." msgstr "Le status a été modifié avec succès."
#: agenda_culturel/views.py:246 #: agenda_culturel/views.py:269
msgid "The event is saved." msgid "The event is saved."
msgstr "L'événement est enregistré." msgstr "L'événement est enregistré."
#: agenda_culturel/views.py:249 #: agenda_culturel/views.py:272
msgid "" msgid ""
"The event has been submitted and will be published as soon as it has been " "The event has been submitted and will be published as soon as it has been "
"validated by the moderation team." "validated by the moderation team."
@ -358,7 +413,7 @@ msgstr ""
"L'événement a été soumis et sera publié dès qu'il aura été validé par " "L'événement a été soumis et sera publié dès qu'il aura été validé par "
"l'équipe de modération." "l'équipe de modération."
#: agenda_culturel/views.py:279 #: agenda_culturel/views.py:302
msgid "" msgid ""
"The event has been successfully extracted, and you can now submit it after " "The event has been successfully extracted, and you can now submit it after "
"modifying it if necessary." "modifying it if necessary."
@ -366,7 +421,7 @@ msgstr ""
"L'événement a été extrait avec succès, vous pouvez maintenant le soumettre " "L'événement a été extrait avec succès, vous pouvez maintenant le soumettre "
"après l'avoir modifié au besoin." "après l'avoir modifié au besoin."
#: agenda_culturel/views.py:283 #: agenda_culturel/views.py:306
msgid "" msgid ""
"Unable to extract an event from the proposed URL. Please use the form below " "Unable to extract an event from the proposed URL. Please use the form below "
"to submit the event." "to submit the event."
@ -374,12 +429,16 @@ msgstr ""
"Impossible d'extraire un événement depuis l'URL proposée. Veuillez utiliser " "Impossible d'extraire un événement depuis l'URL proposée. Veuillez utiliser "
"le formulaire ci-dessous pour soumettre l'événement." "le formulaire ci-dessous pour soumettre l'événement."
#: agenda_culturel/views.py:292 #: agenda_culturel/views.py:315
msgid "This URL has already been submitted, and you can find the event below." msgid "This URL has already been submitted, and you can find the event below."
msgstr "" msgstr ""
"Cette URL a déjà été soumise, et vous trouverez l'événement ci-dessous." "Cette URL a déjà été soumise, et vous trouverez l'événement ci-dessous."
#: agenda_culturel/views.py:296 #: agenda_culturel/views.py:319
msgid "This URL has already been submitted and is awaiting moderation."
msgstr "Cette URL a déjà été soumise, et est en attente de modération"
#: agenda_culturel/views.py:321
msgid "" msgid ""
"This URL has already been submitted, but has not been selected for " "This URL has already been submitted, but has not been selected for "
"publication by the moderation team." "publication by the moderation team."
@ -387,34 +446,87 @@ msgstr ""
"Cette URL a déjà été soumise, mais n'a pas été retenue par l'équipe de " "Cette URL a déjà été soumise, mais n'a pas été retenue par l'équipe de "
"modération pour la publication." "modération pour la publication."
#: agenda_culturel/views.py:298 #: agenda_culturel/views.py:343
msgid "This URL has already been submitted and is awaiting moderation."
msgstr "Cette URL a déjà été soumise, et est en attente de modération"
#: agenda_culturel/views.py:320
msgid "Your message has been sent successfully." msgid "Your message has been sent successfully."
msgstr "L'événement a été supprimé avec succès" msgstr "L'événement a été supprimé avec succès"
#: agenda_culturel/views.py:328 #: agenda_culturel/views.py:351
msgid "The contact message properties has been successfully modified." msgid "The contact message properties has been successfully modified."
msgstr "Les propriétés du message de contact ont été modifié avec succès." msgstr "Les propriétés du message de contact ont été modifié avec succès."
#: agenda_culturel/views.py:343 #: agenda_culturel/views.py:366
msgid "Open" msgid "Open"
msgstr "Ouvert" msgstr "Ouvert"
#: agenda_culturel/views.py:383 #: agenda_culturel/views.py:406
msgid "Search" msgid "Search"
msgstr "Rechercher" msgstr "Rechercher"
#: agenda_culturel/views.py:491 #: agenda_culturel/views.py:521
msgid "The import has been run successfully." msgid "The import has been run successfully."
msgstr "L'import a été lancé avec succès" msgstr "L'import a été lancé avec succès"
#: agenda_culturel/views.py:521 #: agenda_culturel/views.py:537
msgid "The import has been canceled." msgid "The import has been canceled."
msgstr "L'import a été annulé" msgstr "L'import a été annulé"
#: agenda_culturel/views.py:571
msgid "The recurrent import has been successfully modified."
msgstr "L'import récurrent a été modifié avec succès."
#: agenda_culturel/views.py:577
#, fuzzy
#| msgid "The event has been successfully deleted."
msgid "The recurrent import has been successfully deleted."
msgstr "L'import récurrent a été supprimé avec succès"
#: agenda_culturel/views.py:606
#, fuzzy
#| msgid "The import has been canceled."
msgid "The import has been launched."
msgstr "L'import a été annulé"
#: agenda_culturel/views.py:665
msgid "The merge has been successfully completed."
msgstr "La fusion a été réalisée avec succès."
#: agenda_culturel/views.py:694
msgid "Events have been marked as unduplicated."
msgstr "Les événements ont été marqués comme non dupliqués."
#: agenda_culturel/views.py:711
msgid ""
"The selected event has been retained, while the other has been moved "
"to the recycle bin."
msgstr ""
"L'événement sélectionné a été conservé, l'autre a été déplacé dans la "
"corbeille."
#: agenda_culturel/views.py:713
msgid ""
"The selected event has been retained, while the others have been moved "
"to the recycle bin."
msgstr ""
"L'événement sélectionné a été conservé, les autres ont été déplacés dans la "
"corbeille."
#: agenda_culturel/views.py:719
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:765
msgid "The event was successfully duplicated."
msgstr "L'événement a été marqué dupliqué avec succès."
#: agenda_culturel/views.py:768
msgid ""
"The event has been successfully flagged as a duplicate. The moderation team "
"will deal with your suggestion shortly."
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."
msgid "Add another" msgid "Add another"
msgstr "Ajouter un autre" msgstr "Ajouter un autre"

View File

@ -0,0 +1,33 @@
# Generated by Django 4.2.7 on 2024-01-20 15:59
from django.db import migrations, models
import django.db.models.deletion
import django_better_admin_arrayfield.models.fields
class Migration(migrations.Migration):
dependencies = [
('agenda_culturel', '0027_set_dtstart_dtend'),
]
operations = [
migrations.CreateModel(
name='RecurrentImport',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('processor', models.CharField(choices=[('ical', 'ical')], default='ical', max_length=20, verbose_name='Processor')),
('recurrence', models.CharField(choices=[('daily', 'daily'), ('weekly', 'weekly')], default='daily', max_length=10, verbose_name='Import recurrence')),
('source', models.URLField(help_text='URL of the source document', max_length=1024, verbose_name='Source')),
('browsable_url', models.URLField(blank=True, help_text='URL of the corresponding document that will be shown to visitors.', max_length=1024, null=True, verbose_name='Browsable url')),
('defaultLocation', models.CharField(blank=True, help_text='Address for each imported event', max_length=512, null=True, verbose_name='Location')),
('defaultTags', django_better_admin_arrayfield.models.fields.ArrayField(base_field=models.CharField(max_length=64), blank=True, help_text='A list of tags that describe each imported event.', null=True, size=None, verbose_name='Tags for each imported event')),
('defaultCategory', models.ForeignKey(blank=True, help_text='Category of each imported event', null=True, on_delete=django.db.models.deletion.SET_NULL, to='agenda_culturel.category', verbose_name='Category')),
],
),
migrations.AddField(
model_name='batchimportation',
name='recurrentImport',
field=models.ForeignKey(blank=True, editable=False, help_text='Reference to the recurrent import processing', null=True, on_delete=django.db.models.deletion.SET_NULL, to='agenda_culturel.recurrentimport', verbose_name='Recurrent import'),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 4.2.7 on 2024-01-25 21:37
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('agenda_culturel', '0028_recurrentimport_batchimportation_recurrentimport'),
]
operations = [
migrations.RemoveField(
model_name='batchimportation',
name='browsable_url',
),
migrations.RemoveField(
model_name='batchimportation',
name='source',
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.7 on 2024-01-26 10:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agenda_culturel', '0029_remove_batchimportation_browsable_url_and_more'),
]
operations = [
migrations.AddField(
model_name='recurrentimport',
name='downloader',
field=models.CharField(choices=[('simple', 'simple'), ('chromium headless', 'Headless Chromium')], default='simple', max_length=20, verbose_name='Downloader'),
),
]

View File

@ -503,8 +503,9 @@ class Event(models.Model):
for e in events: for e in events:
e.possibly_duplicated = group e.possibly_duplicated = group
# finally update all events (including current) # finally update all events (including current if already created)
Event.objects.bulk_update(events + [self], fields=["possibly_duplicated"]) elist = list(events) + ([self] if self.pk is not None else [])
Event.objects.bulk_update(elist, fields=["possibly_duplicated"])
def data_fields(all=False): def data_fields(all=False):
@ -631,7 +632,7 @@ class Event(models.Model):
nb_updated = Event.objects.bulk_update(to_update, fields = Event.data_fields() + ["imported_date", "modified_date", "uuids", "status"]) nb_updated = Event.objects.bulk_update(to_update, fields = Event.data_fields() + ["imported_date", "modified_date", "uuids", "status"])
nb_draft = 0 nb_draft = 0
if remove_missing_from_source is not None: if remove_missing_from_source is not None and max_date is not None:
# events that are missing from the import but in database are turned into drafts # events that are missing from the import but in database are turned into drafts
# only if they are in the future # only if they are in the future
@ -704,6 +705,41 @@ class ContactMessage(models.Model):
return ContactMessage.objects.filter(closed=False).count() return ContactMessage.objects.filter(closed=False).count()
class RecurrentImport(models.Model):
class PROCESSOR(models.TextChoices):
ICAL = "ical", _("ical")
class DOWNLOADER(models.TextChoices):
SIMPLE = "simple", _("simple")
CHROMIUMHEADLESS = "chromium headless", _("Headless Chromium")
class RECURRENCE(models.TextChoices):
DAILY = "daily", _("daily"),
WEEKLY = "weekly", _("weekly")
processor = models.CharField(_("Processor"), max_length=20, choices=PROCESSOR.choices, default=PROCESSOR.ICAL)
downloader = models.CharField(_("Downloader"), max_length=20, choices=DOWNLOADER.choices, default=DOWNLOADER.SIMPLE)
recurrence = models.CharField(_("Import recurrence"), max_length=10, choices=RECURRENCE.choices, default=RECURRENCE.DAILY)
source = models.URLField(verbose_name=_('Source'), help_text=_("URL of the source document"), max_length=1024)
browsable_url = models.URLField(verbose_name=_('Browsable url'), help_text=_("URL of the corresponding document that will be shown to visitors."), max_length=1024, blank=True, null=True)
defaultLocation = models.CharField(verbose_name=_('Location'), help_text=_('Address for each imported event'), max_length=512, null=True, blank=True)
defaultCategory = models.ForeignKey(Category, verbose_name=_('Category'), help_text=_('Category of each imported event'), blank=True, null=True, on_delete=models.SET_NULL)
defaultTags = ArrayField(models.CharField(max_length=64), verbose_name=_('Tags for each imported event'), help_text=_("A list of tags that describe each imported event."), blank=True, null=True)
def nb_imports(self):
return BatchImportation.objects.filter(recurrentImport=self).count()
def get_absolute_url(self):
return reverse("view_rimport", kwargs={"pk": self.pk})
class BatchImportation(models.Model): class BatchImportation(models.Model):
class STATUS(models.TextChoices): class STATUS(models.TextChoices):
@ -715,8 +751,7 @@ class BatchImportation(models.Model):
created_date = models.DateTimeField(auto_now_add=True) created_date = models.DateTimeField(auto_now_add=True)
source = models.URLField(verbose_name=_('Source'), help_text=_("URL of the source document"), max_length=1024, blank=True, null=True) recurrentImport = models.ForeignKey(RecurrentImport, verbose_name=_('Recurrent import'), help_text=_('Reference to the recurrent import processing'), blank=True, null=True, on_delete=models.SET_NULL, editable=False)
browsable_url = models.URLField(verbose_name=_('Browsable url'), help_text=_("URL of the corresponding document that will be shown to visitors."), max_length=1024, blank=True, null=True)
status = models.CharField(_("Status"), max_length=20, choices=STATUS.choices, default=STATUS.RUNNING) status = models.CharField(_("Status"), max_length=20, choices=STATUS.choices, default=STATUS.RUNNING)

View File

@ -774,4 +774,10 @@ article>article {
.strike { .strike {
text-decoration: line-through; text-decoration: line-through;
}
.buttons {
display: flex;
flex: auto;
gap: 0.4em;
} }

View File

@ -0,0 +1,48 @@
<table role="grid">
<thead>
<tr>
<th rowspan="2">Identifiant</th>
<th rowspan="2">Date</th>
<th rowspan="2">Status</th>
<th rowspan="2">Action</th>
<th colspan="4">événements</th>
</tr>
<tr>
<th>initial</th>
<th>importés</th>
<th>mis à jour</th>
<th>dépubliés</th>
</tr>
</thead>
<tbody>
{% for obj in paginator_filter %}
<tr>
<td>{{ obj.id }}</a></td>
<td>{{ obj.created_date }}</td>
<td><span{% if obj.status == "failed" %} data-tooltip="{{ obj.error_message }}"{% endif %}>{{ obj.status }}</span></td>
<td>{% if obj.status == "running" %}<a href="{% url 'cancel_import' obj.id %}">Annuler</a>{% endif %}</td>
<td>{% if obj.status == "success" %}{{ obj.nb_initial }}{% endif %}</td>
<td>{% if obj.status == "success" %}{{ obj.nb_imported }}{% endif %}</td>
<td>{% if obj.status == "success" %}{{ obj.nb_updated }}{% endif %}</td>
<td>{% if obj.status == "success" %}{{ obj.nb_removed }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
<footer>
<span>
{% if paginator_filter.has_previous %}
<a href="?page=1" role="button">&laquo; premier</a>
<a href="?page={{ paginator_filter.previous_page_number }}" role="button">précédent</a>
{% endif %}
<span>
Page {{ paginator_filter.number }} sur {{ paginator_filter.paginator.num_pages }}
</span>
{% if paginator_filter.has_next %}
<a href="?page={{ paginator_filter.next_page_number }}" role="button">suivant</a>
<a href="?page={{ paginator_filter.paginator.num_pages }}" role="button">dernier &raquo;</a>
{% endif %}
</span>
</footer>

View File

@ -1,12 +1,12 @@
{% extends "agenda_culturel/page.html" %} {% extends "agenda_culturel/page.html" %}
{% load static %} {% load static %}
{% block title %}Importation par lot{% endblock %} {% block title %}Importation manuelle{% endblock %}
{% block content %} {% block content %}
<h1>Importation par lot</h1> <h1>Importation manuelle</h1>
<article> <article>
<form method="post">{% csrf_token %} <form method="post">{% csrf_token %}

View File

@ -12,58 +12,11 @@
<div class="grid two-columns"> <div class="grid two-columns">
<article> <article>
<header> <header>
<a class="slide-buttons" href="{% url 'add_import'%}" role="button">Nouvel import</a> <a class="slide-buttons" href="{% url 'add_import'%}" role="button">Import manuel</a>
<h1>Importations par lot</h1> <h1>Importations par lot</h1>
</header> </header>
<table role="grid"> {% include "agenda_culturel/batch-imports-inc.html" with paginator_filter=paginator_filter %}
<thead>
<tr>
<th rowspan="2">Identifiant</th>
<th rowspan="2">Date</th>
<th rowspan="2">Status</th>
<th rowspan="2">Action</th>
<th colspan="4">événements</th>
</tr>
<tr>
<th>initial</th>
<th>importés</th>
<th>mis à jour</th>
<th>dépubliés</th>
</tr>
</thead>
<tbody>
{% for obj in paginator_filter %}
<tr>
<td>{{ obj.id }}</a></td>
<td>{{ obj.created_date }}</td>
<td><span{% if obj.status == "failed" %} data-tooltip="{{ obj.error_message }}"{% endif %}>{{ obj.status }}</span></td>
<td>{% if obj.status == "running" %}<a href="{% url 'cancel_import' obj.id %}">Annuler</a>{% endif %}</td>
<td>{% if obj.status == "success" %}{{ obj.nb_initial }}{% endif %}</td>
<td>{% if obj.status == "success" %}{{ obj.nb_imported }}{% endif %}</td>
<td>{% if obj.status == "success" %}{{ obj.nb_updated }}{% endif %}</td>
<td>{% if obj.status == "success" %}{{ obj.nb_removed }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
<footer>
<span>
{% if paginator_filter.has_previous %}
<a href="?page=1" role="button">&laquo; premier</a>
<a href="?page={{ paginator_filter.previous_page_number }}" role="button">précédent</a>
{% endif %}
<span>
Page {{ paginator_filter.number }} sur {{ paginator_filter.paginator.num_pages }}
</span>
{% if paginator_filter.has_next %}
<a href="?page={{ paginator_filter.next_page_number }}" role="button">suivant</a>
<a href="?page={{ paginator_filter.paginator.num_pages }}" role="button">dernier &raquo;</a>
{% endif %}
</span>
</footer>
</article> </article>
{% include "agenda_culturel/side-nav.html" with current="imports" %} {% include "agenda_culturel/side-nav.html" with current="imports" %}

View File

@ -0,0 +1,53 @@
{% extends "agenda_culturel/page.html" %}
{% block title %}Import récurrent #{{ object.pk }}{% endblock %}
{% load cat_extra %}
{% load utils_extra %}
{% block entete_header %}
{% css_categories %}
{% endblock %}
{% block content %}
<div class="grid two-columns">
<article>
<header>
<a href="{% url 'recurrent_imports' %}" role="button">&lt; Retour</a>
<div class="buttons slide-buttons">
<a href="{% url 'run_rimport' object.pk %}" role="button">Exécuter {% picto_from_name "download-cloud" %}</a>
<a href="{% url 'edit_rimport' object.pk %}" role="button">Éditer {% picto_from_name "edit" %}</a>
<a href="{% url 'delete_rimport' object.pk %}" role="button">Supprimer {% picto_from_name "trash" %}</a>
</div>
<h1>Import récurrent #{{ object.pk }}</h1>
<ul>
<li><strong>Processeur&nbsp;:</strong> {{ object.processor }}</li>
<li><strong>Téléchargeur&nbsp;:</strong> {{ object.downloader }}</li>
<li><strong>Recurrence&nbsp;:</strong> {{ object.recurrence }}</li>
<li><strong>Source&nbsp;:</strong> <a href="{{ object.source }}">{{ object.source }}</a></li>
<li><strong>Adresse naviguable&nbsp;:</strong> <a href="{{ object.browsable_url }}">{{ object.browsable_url }}</a></li>
<li><strong>Valeurs par défaut&nbsp;:</strong>
<ul>
<li><strong>Localisation&nbsp;:</strong> {{ object.defaultLocation }}</li>
<li><strong>Catégorie&nbsp;:</strong> {{ object.defaultCategory }}</li>
<li><strong>Étiquettes&nbsp;:</strong>
{% for tag in object.defaultTags %}
{{ tag }}{% if not forloop.last %}, {% endif %}
{% endfor %}
</li>
</ul>
</li>
</ul>
</header>
{% include "agenda_culturel/batch-imports-inc.html" with paginator_filter=paginator_filter %}
</article>
{% include "agenda_culturel/side-nav.html" with current="rimports" %}
</div>
{% endblock %}

View File

@ -0,0 +1,18 @@
{% extends "agenda_culturel/page.html" %}
{% block title %}Supprimer {{ object.title }}{% endblock %}
{% block content %}
<h1>Suppression de l'import récurrent {{ object.pk }}</h1>
<form method="post">{% csrf_token %}
<p>Êtes-vous sûr·e de vouloir supprimer l'import récurrent «&nbsp;{{ object.pk }}&nbsp;»&nbsp; correspondant à la source <a href="{{ object.source }}">{{ object.source }}</a>&nbsp;?</p>
{{ form }}
<div class="grid">
<a href="{% if request.META.HTTP_REFERER %}{{ request.META.HTTP_REFERER }}{% else %}{{ object.get_absolute_url }}{% endif %}" role="button" class="secondary">Annuler</a>
<input type="submit" value="Confirmer">
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,27 @@
{% extends "agenda_culturel/page.html" %}
{% load static %}
{% block title %}Importation récurrente{% endblock %}
{% block entete_header %}
<script src="/static/admin/js/vendor/jquery/jquery.js"></script>
<script src="/static/admin/js/jquery.init.js"></script>
<link href="{% static 'css/django_better_admin_arrayfield.min.css' %}" type="text/css" media="all" rel="stylesheet">
<script src="{% static 'js/django_better_admin_arrayfield.min.js' %}"></script>
{% endblock %}
{% block content %}
<h1>Importation récurrente</h1>
<article>
<form method="post">{% csrf_token %}
{{ form.as_p }}
<div class="grid">
<a href="{% if request.META.HTTP_REFERER %}{{ request.META.HTTP_REFERER }}{% else %}{{ object.get_absolute_url }}{% endif %}" role="button" class="secondary">Annuler</a>
<input type="submit" value="Envoyer">
</div>
</form>
</article>
{% endblock %}

View File

@ -0,0 +1,75 @@
{% extends "agenda_culturel/page.html" %}
{% block title %}Importations récurrentes{% endblock %}
{% load utils_extra %}
{% load cat_extra %}
{% block entete_header %}
{% css_categories %}
{% endblock %}
{% block content %}
<div class="grid two-columns">
<article>
<header>
<a class="slide-buttons" href="{% url 'add_rimport'%}" role="button">Ajouter {% picto_from_name "plus-circle" %}</a>
<h1>Importations récurrentes</h1>
</header>
<table role="grid">
<thead>
<tr>
<th>Identifiant</th>
<th>Processeur</th>
<th>Téléchargeur</th>
<th>Récurrence</th>
<th>Source</th>
<th>Adresse naviguable</th>
<th>Nombre d'imports</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for obj in paginator_filter %}
<tr>
<td>{{ obj.pk }}</a></td>
<td>{{ obj.processor }}</td>
<td>{{ obj.downloader }}</td>
<td>{{ obj.recurrence }}</td>
<td><a href="{{ obj.source }}">{{ obj.source|truncatechars_middle:32 }}</a></td>
<td><a href="{{ obj.browsable_url }}">{{ obj.browsable_url|truncatechars_middle:32 }}</a></td>
<td>{{ obj.nb_imports }}</td>
<td>
<div class="buttons">
<a href="{% url 'run_rimport' obj.pk %}" role="button">Exécuter</a>
<a href="{% url 'view_rimport' obj.pk %}" role="button">Consulter</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<footer>
<span>
{% if paginator_filter.has_previous %}
<a href="?page=1" role="button">&laquo; premier</a>
<a href="?page={{ paginator_filter.previous_page_number }}" role="button">précédent</a>
{% endif %}
<span>
Page {{ paginator_filter.number }} sur {{ paginator_filter.paginator.num_pages }}
</span>
{% if paginator_filter.has_next %}
<a href="?page={{ paginator_filter.next_page_number }}" role="button">suivant</a>
<a href="?page={{ paginator_filter.paginator.num_pages }}" role="button">dernier &raquo;</a>
{% endif %}
</span>
</footer>
</article>
{% include "agenda_culturel/side-nav.html" with current="rimports" %}
</div>
{% endblock %}

View File

@ -0,0 +1,25 @@
{% extends "agenda_culturel/page.html" %}
{% block title %}Lancer l'import #{{ object.pk }}{% endblock %}
{% block content %}
<article>
<header>
<h1>Lancement de l'import #{{ object.pk }}</h1>
</header>
<form method="post">{% csrf_token %}
<p>Êtes-vous sûr·e de vouloir lancer l'import récurrent «&nbsp;{{ object.pk }}&nbsp;»&nbsp; correspondant à la source <a href="{{ object.source }}">{{ object.source }}</a>&nbsp;?</p>
{{ form }}
<footer>
<div class="grid">
<a href="{% if request.META.HTTP_REFERER %}{{ request.META.HTTP_REFERER }}{% else %}{% url 'recurrent_imports' %}{% endif %}" role="button" class="secondary">Annuler</a>
<input type="submit" value="Confirmer">
</div>
</footer>
</form>
</article>
{% endblock %}

View File

@ -21,7 +21,17 @@
<li><a {% if current == "moderation" %}class="selected" {% endif %}href="{% url 'moderation' %}">Derniers événements soumis</a>{% show_badges_events "left" %}</li> <li><a {% if current == "moderation" %}class="selected" {% endif %}href="{% url 'moderation' %}">Derniers événements soumis</a>{% show_badges_events "left" %}</li>
<li><a {% if current == "contactmessages" %}class="selected" {% endif %}href="{% url 'contactmessages' %}">Messages de contact</a>{% show_badge_contactmessages "left" %}</li> <li><a {% if current == "contactmessages" %}class="selected" {% endif %}href="{% url 'contactmessages' %}">Messages de contact</a>{% show_badge_contactmessages "left" %}</li>
<li><a {% if current == "duplicates" %}class="selected" {% endif %}href="{% url 'duplicates' %}">Gestion des doublons</a>{% show_badge_duplicated "left" %}</li> <li><a {% if current == "duplicates" %}class="selected" {% endif %}href="{% url 'duplicates' %}">Gestion des doublons</a>{% show_badge_duplicated "left" %}</li>
<li><a {% if current == "imports" %}class="selected" {% endif %}href="{% url 'imports' %}">Importations par lot</a></li> </ul>
</nav>
</article>
<article>
<header>
<h2>Importations</h2>
</header>
<nav>
<ul>
<li><a {% if current == "imports" %}class="selected" {% endif %}href="{% url 'imports' %}">Historiques des importations</a></li>
<li><a {% if current == "rimports" %}class="selected" {% endif %}href="{% url 'recurrent_imports' %}">Importations récurrentes</a></li>
</ul> </ul>
</nav> </nav>
</article> </article>

View File

@ -1,5 +1,6 @@
from django import template from django import template
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.template.defaultfilters import stringfilter
from urllib.parse import urlparse from urllib.parse import urlparse
from datetime import timedelta, date from datetime import timedelta, date
@ -71,3 +72,15 @@ def picto_from_name(name, datatooltip=""):
@register.filter @register.filter
def int_to_abc(d): def int_to_abc(d):
return auc[int(d)] return auc[int(d)]
@register.filter(is_safe=True)
@stringfilter
def truncatechars_middle(value, arg):
try:
ln = int(arg)
except ValueError:
return value
if len(value) <= ln:
return value
else:
return '{}...{}'.format(value[:ln//2], value[-((ln+1)//2):])

View File

@ -38,8 +38,14 @@ urlpatterns = [
path('contactmessages', contactmessages, name='contactmessages'), path('contactmessages', contactmessages, name='contactmessages'),
path('contactmessage/<int:pk>', ContactMessageUpdateView.as_view(), name='contactmessage'), path('contactmessage/<int:pk>', ContactMessageUpdateView.as_view(), name='contactmessage'),
path("imports/", imports, name="imports"), path("imports/", imports, name="imports"),
path("imports/add", BatchImportationCreateView.as_view(), name="add_import"), path("imports/add", add_import, name="add_import"),
path("imports/<int:pk>/cancel", cancel_import, name="cancel_import"), path("imports/<int:pk>/cancel", cancel_import, name="cancel_import"),
path("rimports/", recurrent_imports, name="recurrent_imports"),
path("rimports/add", RecurrentImportCreateView.as_view(), name="add_rimport"),
path("rimports/<int:pk>/view", view_rimport, name="view_rimport"),
path("rimports/<int:pk>/edit", RecurrentImportUpdateView.as_view(), name="edit_rimport"),
path("rimports/<int:pk>/delete", RecurrentImportDeleteView.as_view(), name="delete_rimport"),
path("rimports/<int:pk>/run", run_rimport, name="run_rimport"),
path("duplicates/", duplicates, name="duplicates"), path("duplicates/", duplicates, name="duplicates"),
path("duplicates/<int:pk>", DuplicatedEventsDetailView.as_view(), name="view_duplicate"), path("duplicates/<int:pk>", DuplicatedEventsDetailView.as_view(), name="view_duplicate"),
path("duplicates/<int:pk>/fix", fix_duplicate, name="fix_duplicate"), path("duplicates/<int:pk>/fix", fix_duplicate, name="fix_duplicate"),

View File

@ -11,9 +11,9 @@ from django.http import HttpResponseRedirect
from django.urls import reverse from django.urls import reverse
import urllib import urllib
from .forms import EventSubmissionForm, EventForm, BatchImportationForm, FixDuplicates, SelectEventInList, MergeDuplicates from .forms import EventSubmissionForm, EventForm, BatchImportationForm, FixDuplicates, SelectEventInList, MergeDuplicates, RecurrentImportForm
from .models import Event, Category, StaticContent, ContactMessage, BatchImportation, DuplicatedEvents from .models import Event, Category, StaticContent, ContactMessage, BatchImportation, DuplicatedEvents, RecurrentImport
from django.utils import timezone from django.utils import timezone
from enum import StrEnum from enum import StrEnum
from datetime import date, timedelta from datetime import date, timedelta
@ -32,7 +32,7 @@ from django.contrib.messages.views import SuccessMessageMixin
from .calendar import CalendarMonth, CalendarWeek, CalendarDay from .calendar import CalendarMonth, CalendarWeek, CalendarDay
from .extractors import ExtractorAllURLs from .extractors import ExtractorAllURLs
from .celery import app as celery_app, import_events_from_json, import_events_from_url from .celery import app as celery_app, import_events_from_json, run_recurrent_import
import unicodedata import unicodedata
import logging import logging
@ -507,28 +507,21 @@ def imports(request):
return render(request, 'agenda_culturel/imports.html', {'paginator_filter': response} ) return render(request, 'agenda_culturel/imports.html', {'paginator_filter': response} )
class BatchImportationCreateView(SuccessMessageMixin, LoginRequiredMixin, CreateView): @login_required(login_url="/accounts/login/")
model = BatchImportation def add_import(request):
form = BatchImportationForm()
success_url = reverse_lazy('imports') if request.method == 'POST':
success_message = _('The import has been run successfully.') form = BatchImportationForm(request.POST)
form_class = BatchImportationForm if form.is_valid():
def form_valid(self, form):
# run import
if "json" in form.data and form.data["json"] is not None and form.data["json"].strip() != "":
result = import_events_from_json.delay(form.data["json"]) result = import_events_from_json.delay(form.data["json"])
else:
result = import_events_from_url.delay(form.data["source"], form.data["browsable_url"])
# update the object with celery_id messages.success(request, _("The import has been run successfully."))
form.instance.celery_id = result.id return HttpResponseRedirect(reverse_lazy("imports"))
return super().form_valid(form)
return render(request, 'agenda_culturel/batchimportation_form.html', {"form": form})
@login_required(login_url="/accounts/login/") @login_required(login_url="/accounts/login/")
@ -547,6 +540,78 @@ def cancel_import(request, pk):
cancel_url = reverse_lazy("imports") cancel_url = reverse_lazy("imports")
return render(request, 'agenda_culturel/cancel_import_confirm.html', {"object": import_process, "cancel_url": cancel_url}) return render(request, 'agenda_culturel/cancel_import_confirm.html', {"object": import_process, "cancel_url": cancel_url})
#########################
## recurrent importations
#########################
@login_required(login_url="/accounts/login/")
def recurrent_imports(request):
paginator = Paginator(RecurrentImport.objects.all().order_by("-pk"), 10)
page = request.GET.get('page')
try:
response = paginator.page(page)
except PageNotAnInteger:
response = paginator.page(1)
except EmptyPage:
response = paginator.page(paginator.num_pages)
return render(request, 'agenda_culturel/rimports.html', {'paginator_filter': response} )
class RecurrentImportCreateView(LoginRequiredMixin, CreateView):
model = RecurrentImport
success_url = reverse_lazy('recurrent_imports')
form_class = RecurrentImportForm
class RecurrentImportUpdateView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
model = RecurrentImport
form_class = RecurrentImportForm
success_message = _('The recurrent import has been successfully modified.')
class RecurrentImportDeleteView(SuccessMessageMixin, LoginRequiredMixin, DeleteView):
model = RecurrentImport
success_url = reverse_lazy('recurrent_imports')
success_message = _('The recurrent import has been successfully deleted.')
@login_required(login_url="/accounts/login/")
def view_rimport(request, pk):
obj = get_object_or_404(RecurrentImport, pk=pk)
paginator = Paginator(BatchImportation.objects.filter(recurrentImport=pk).order_by("-created_date"), 10)
page = request.GET.get('page')
try:
response = paginator.page(page)
except PageNotAnInteger:
response = paginator.page(1)
except EmptyPage:
response = paginator.page(paginator.num_pages)
return render(request, 'agenda_culturel/page-rimport.html', {'paginator_filter': response, 'object': obj} )
@login_required(login_url="/accounts/login/")
def run_rimport(request, pk):
rimport = get_object_or_404(RecurrentImport, pk=pk)
if request.method == 'POST':
# run recurrent import
result = run_recurrent_import.delay(pk)
messages.success(request, _("The import has been launched."))
return HttpResponseRedirect(reverse_lazy("view_rimport", args=[pk]))
else:
return render(request, 'agenda_culturel/run_rimport_confirm.html', {"object": rimport })
#########################
## duplicated events
#########################
class DuplicatedEventsDetailView(LoginRequiredMixin, DetailView): class DuplicatedEventsDetailView(LoginRequiredMixin, DetailView):
model = DuplicatedEvents model = DuplicatedEvents
@ -597,7 +662,7 @@ def merge_duplicate(request, pk):
e.status = Event.STATUS.TRASH e.status = Event.STATUS.TRASH
Event.objects.bulk_update(events, fields=["status"]) Event.objects.bulk_update(events, fields=["status"])
messages.info(request, _("La fusion a été réalisée avec succès.")) messages.info(request, _("The merge has been successfully completed."))
return HttpResponseRedirect(new_event.get_absolute_url()) return HttpResponseRedirect(new_event.get_absolute_url())
return render(request, 'agenda_culturel/merge_duplicate.html', context={'form': form, 'object': edup}) return render(request, 'agenda_culturel/merge_duplicate.html', context={'form': form, 'object': edup})
@ -626,7 +691,7 @@ def fix_duplicate(request, pk):
s_event = events[0] s_event = events[0]
date = s_event.start_day date = s_event.start_day
messages.success(request, _("Les événements ont été marqués comme non dupliqués.")) messages.success(request, _("Events have been marked as unduplicated."))
edup.delete() edup.delete()
if date is None: if date is None:
return HttpResponseRedirect(reverse_lazy("home")) return HttpResponseRedirect(reverse_lazy("home"))
@ -643,15 +708,15 @@ def fix_duplicate(request, pk):
url = selected.get_absolute_url() url = selected.get_absolute_url()
edup.delete() edup.delete()
if nb == 1: if nb == 1:
messages.success(request, _("L'événement sélectionné a été conservé, l'autre a été déplacé dans la corbeille.")) messages.success(request, _("The selected event has been retained, while the other has been moved to the recycle bin."))
else: else:
messages.success(request, _("L'événement sélectionné a été conservé, les autres ont été déplacés dans la corbeille.")) messages.success(request, _("The selected event has been retained, while the others have been moved to the recycle bin."))
return HttpResponseRedirect(url) return HttpResponseRedirect(url)
elif form.is_action_remove(): elif form.is_action_remove():
event = form.get_selected_event(edup) event = form.get_selected_event(edup)
event.possibly_duplicated = None event.possibly_duplicated = None
event.save() event.save()
messages.success(request, _("L'événement a été retiré du groupe et rendu indépendant.")) messages.success(request, _("The event has been withdrawn from the group and made independent."))
if edup.nb_duplicated() == 1: if edup.nb_duplicated() == 1:
return HttpResponseRedirect(event.get_absolute_url()) return HttpResponseRedirect(event.get_absolute_url())
else: else:
@ -697,10 +762,10 @@ def set_duplicate(request, year, month, day, pk):
event.set_possibly_duplicated(selected) event.set_possibly_duplicated(selected)
event.save() event.save()
if request.user.is_authenticated: if request.user.is_authenticated:
messages.success(request, _("L'événement a été marqué dupliqué avec succès.")) messages.success(request, _("The event was successfully duplicated."))
return HttpResponseRedirect(reverse_lazy("view_duplicate", args=[event.possibly_duplicated.pk])) return HttpResponseRedirect(reverse_lazy("view_duplicate", args=[event.possibly_duplicated.pk]))
else: else:
messages.info(request, _("L'événement a été signalé comme dupliqué avec succès. Votre suggestion sera prochainement prise en charge par l'équipe de modération.")) messages.info(request, _("The event has been successfully flagged as a duplicate. The moderation team will deal with your suggestion shortly."))
return HttpResponseRedirect(event.get_absolute_url()) return HttpResponseRedirect(event.get_absolute_url())

View File

@ -33,3 +33,6 @@ django-compressor==4.4
django-libsass==0.9 django-libsass==0.9
django-ckeditor==6.7.0 django-ckeditor==6.7.0
django-recurrence==1.11.1 django-recurrence==1.11.1
icalendar==5.0.11
lxml==5.1.0