diff --git a/.env.example b/.env.example index fa3f8230f..3c093d69e 100644 --- a/.env.example +++ b/.env.example @@ -41,8 +41,10 @@ WMTS_GETCAP=https://wmts.asit-asso.ch/wmts?request=GetCapabilities WMTS_LAYER=asitvd.fond_cadastral WMTS_GETCAP_ALTERNATIVE=https://wmts.geo.admin.ch/EPSG/2056/1.0.0/WMTSCapabilities.xml WMTS_LAYER_ALTERNATIVE=ch.swisstopo.swissimage -PRIVATE_DOCUMENTS_DIR=/var/sig/private_geocity/geocity_demo # PRIVATE_DOCUMENTS_DIR=private_documents used to access to the private documents via a volume (check docker-compose-dev.yml) search(ctrl+f) -> #access_to_private_document_local +PRIVATE_DOCUMENTS_DIR=/var/sig/private_geocity/geocity_demo +# PUBLIC_DOCUMENTS_DIR=public_documents used to access to the public documents via a volume (check docker-compose-dev.yml) +PUBLIC_DOCUMENTS_DIR=/var/sig/public_geocity/geocity_demo ALLOWED_HOSTS=localhost,127.0.0.1,web,yverdon.localhost,vevey.localhost,lausanne.localhost,grandson.localhost,base.localhost ALLOWED_CORS=http://localhost:3000,http://127.0.0.1 # This setting will enable the factor authentification diff --git a/README.md b/README.md index 8626cbcc7..b7aa28882 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,7 @@ Following variable setup in your .env file will setup the development environmen ```ini PRIVATE_DOCUMENTS_DIR=C:\some\directory\for\mounting\geocity\private_documents +PUBLIC_DOCUMENTS_DIR=C:\some\directory\for\mounting\geocity\public_documents ARCHIVE_DIR=C:\some\directory\for\mounting\geocity\archive DEFAULT_SITE=localhost ``` diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index ee5b74ea1..c847f8558 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -20,6 +20,7 @@ services: - .:/code # For dev, set the temp folder in the main code directory, for convenience - ./_dev_volumes/private_documents:/private_documents + - ./_dev_volumes/public_documents:/public_documents - ./_dev_volumes/archive:/archive depends_on: postgres: diff --git a/docker-compose.yml b/docker-compose.yml index 3389ee89e..7c0cc4ddd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: command: "gunicorn geocity.wsgi -b :9000 --error-logfile gunicorn_log.log --workers=2 --threads=4 --worker-class=gthread" volumes: - ${PRIVATE_DOCUMENTS_DIR}:/private_documents + - ${PUBLIC_DOCUMENTS_DIR}:/public_documents - ${ARCHIVE_DIR}:/archive - static_root:/external_statics # to allow to spawn new QGIS containers @@ -47,6 +48,7 @@ services: WMTS_GETCAP_ALTERNATIVE: WMTS_LAYER_ALTERNATIVE: PRIVATE_DOCUMENTS_DIR: + PUBLIC_DOCUMENTS_DIR: ALLOWED_HOSTS: ALLOWED_CORS: ENABLE_2FA: diff --git a/geocity/apps/accounts/admin.py b/geocity/apps/accounts/admin.py index 1eb9eabb6..e466f849b 100644 --- a/geocity/apps/accounts/admin.py +++ b/geocity/apps/accounts/admin.py @@ -502,7 +502,8 @@ def clean_permissions(self): class UserInline(admin.TabularInline): model = Group.user_set.through - can_delete = True + readonly_fields = ("user",) + can_delete = False extra = 0 verbose_name = _("Utilisateur membre du groupe") verbose_name_plural = _("Utilisateurs membres du groupe") @@ -515,6 +516,12 @@ def formfield_for_foreignkey(self, db_field, request, **kwargs): return super().formfield_for_foreignkey(db_field, request, **kwargs) + def has_add_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + class GroupAdmin(BaseGroupAdmin): inlines = (PermitDepartmentInline, UserInline) @@ -774,6 +781,7 @@ class AdministrativeEntityAdmin(IntegratorFilterMixin, admin.ModelAdmin): { "fields": ( "name", + "agenda_name", "tags", "ofs_id", "is_single_form_submissions", diff --git a/geocity/apps/accounts/fields.py b/geocity/apps/accounts/fields.py index e72d2f4ce..ef2935bb6 100644 --- a/geocity/apps/accounts/fields.py +++ b/geocity/apps/accounts/fields.py @@ -2,7 +2,7 @@ from django.db.models.fields.files import FieldFile from django.urls import reverse -from geocity.fields import PrivateFileSystemStorage +from geocity.fields import PrivateFileSystemStorage, PublicFileSystemStorage class AdministrativeEntityFieldFile(FieldFile): @@ -24,3 +24,29 @@ class AdministrativeEntityFileField(models.FileField): def __init__(self, verbose_name=None, name=None, **kwargs): kwargs["storage"] = PrivateFileSystemStorage() super().__init__(verbose_name, name, **kwargs) + + +class CustomLoginImageFieldFile(FieldFile): + """ + FieldFile for storing public image for site customization + """ + + @property + def url(self): + + return reverse( + "accounts:site_profile_custom_image", + kwargs={"path": self.name}, + ) + + +class CustomLoginImageFileField(models.FileField): + """ + FileField storing public image for site customization + """ + + attr_class = CustomLoginImageFieldFile + + def __init__(self, verbose_name=None, name=None, **kwargs): + kwargs["storage"] = PublicFileSystemStorage() + super().__init__(verbose_name, name, **kwargs) diff --git a/geocity/apps/accounts/migrations/0018_administrativeentity_agenda_name_and_more.py b/geocity/apps/accounts/migrations/0018_administrativeentity_agenda_name_and_more.py new file mode 100644 index 000000000..4083e2f81 --- /dev/null +++ b/geocity/apps/accounts/migrations/0018_administrativeentity_agenda_name_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.10 on 2024-04-18 06:19 + +import django.core.validators +from django.db import migrations, models + +import geocity.apps.accounts.fields +import geocity.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0017_service_fee"), + ] + + operations = [ + migrations.AddField( + model_name="administrativeentity", + name="agenda_name", + field=models.CharField( + blank=True, + help_text="Nom visible dans le filtre de l'agenda", + max_length=128, + verbose_name="Nom dans l'api agenda", + ), + ), + migrations.AlterField( + model_name="templatecustomization", + name="background_image", + field=geocity.apps.accounts.fields.CustomLoginImageFileField( + blank=True, + storage=geocity.fields.PublicFileSystemStorage(), + upload_to="site_profile_custom_image/", + validators=[ + django.core.validators.FileExtensionValidator( + allowed_extensions=["svg", "png", "jpg", "jpeg"] + ) + ], + verbose_name="Image de fond", + ), + ), + ] diff --git a/geocity/apps/accounts/models.py b/geocity/apps/accounts/models.py index 1296960f5..0f18c7370 100644 --- a/geocity/apps/accounts/models.py +++ b/geocity/apps/accounts/models.py @@ -18,7 +18,7 @@ from simple_history.models import HistoricalRecords from taggit.managers import TaggableManager -from .fields import AdministrativeEntityFileField +from .fields import AdministrativeEntityFileField, CustomLoginImageFileField AGENDA_PUBLIC_TYPE_CHOICES = ( ( @@ -116,10 +116,10 @@ class TemplateCustomization(models.Model): application_description = models.TextField( _("Description"), max_length=2048, blank=True ) - background_image = models.ImageField( + background_image = CustomLoginImageFileField( _("Image de fond"), blank=True, - upload_to="background_images/", + upload_to="site_profile_custom_image/", validators=[ FileExtensionValidator(allowed_extensions=["svg", "png", "jpg", "jpeg"]) ], @@ -257,6 +257,12 @@ def associated_to_user(self, user): class AdministrativeEntity(models.Model): name = models.CharField(_("name"), max_length=128) + agenda_name = models.CharField( + _("Nom dans l'api agenda"), + help_text=_("Nom visible dans le filtre de l'agenda"), + max_length=128, + blank=True, + ) ofs_id = models.PositiveIntegerField(_("Numéro OFS")) link = models.URLField(_("Lien"), max_length=200, blank=True) archive_link = models.URLField(_("Archives externes"), max_length=1024, blank=True) diff --git a/geocity/apps/accounts/templates/accounts/admin/administrative_entity_change.html b/geocity/apps/accounts/templates/accounts/admin/administrative_entity_change.html index 167fabcdf..19a55fc3b 100644 --- a/geocity/apps/accounts/templates/accounts/admin/administrative_entity_change.html +++ b/geocity/apps/accounts/templates/accounts/admin/administrative_entity_change.html @@ -4,9 +4,9 @@ {% block object-tools-items %} {% if original.anonymous_user %}
- - {% translate "Voir l'utilisateur anonyme" %} - + + {% translate "Utilisateur anonyme déjà existant" %} +
{% else %}
diff --git a/geocity/apps/accounts/urls.py b/geocity/apps/accounts/urls.py index 7b9791627..2c8c61bfb 100644 --- a/geocity/apps/accounts/urls.py +++ b/geocity/apps/accounts/urls.py @@ -78,4 +78,9 @@ views.administrative_entity_file_download, name="administrative_entity_file_download", ), + path( + "custom-site-image/", + views.site_profile_custom_image, + name="site_profile_custom_image", + ), ] diff --git a/geocity/apps/accounts/users.py b/geocity/apps/accounts/users.py index 3a8968a3d..0e6fc520e 100644 --- a/geocity/apps/accounts/users.py +++ b/geocity/apps/accounts/users.py @@ -52,67 +52,47 @@ def get_integrator_permissions(): def get_users_list_for_integrator_admin(user, remove_anonymous=False): - # Integrators can only view users for restricted email domains. if user.is_superuser: qs = User.objects.select_related("userprofile") - - # Used to remove anonymous users from the list - anonymous_users = [] - if remove_anonymous: - for user in qs: - if user.userprofile.is_anonymous: - anonymous_users.append(user.pk) - qs = qs.exclude(pk__in=anonymous_users) - - return qs - - user_integrator_group = user.groups.get(permit_department__is_integrator_admin=True) - - email_domains = [ - domain.strip() - for domain in user_integrator_group.permit_department.integrator_email_domains.split( - "," - ) - ] - emails = [ - email.strip() - for email in user_integrator_group.permit_department.integrator_emails_exceptions.split( - "," + else: + user_integrator_group = user.groups.get( + permit_department__is_integrator_admin=True ) - ] - qs = ( - User.objects.annotate( - email_domain=Substr("email", StrIndex("email", Value("@")) + 1), + email_domains = [ + domain.strip() + for domain in user_integrator_group.permit_department.integrator_email_domains.split( + "," + ) + ] + + emails = [ + email.strip() + for email in user_integrator_group.permit_department.integrator_emails_exceptions.split( + "," + ) + ] + + qs = ( + User.objects.annotate( + email_domain=Substr("email", StrIndex("email", Value("@")) + 1), + ) + # hide users not belonging to the actual integrator + .filter( + Q(is_superuser=False), + Q(email_domain__in=email_domains) | Q(email__in=emails), + Q(groups__permit_department__integrator=user_integrator_group.pk) + | Q(groups__isnull=True) + | Q(groups__permit_department__is_integrator_admin=True), + ).distinct() ) - # hide anynomous user not belonging to the actual integrator - .filter( - Q(is_superuser=False), - Q(email_domain__in=email_domains) | Q(email__in=emails), - Q(groups__permit_department__integrator=user_integrator_group.pk) - | Q(groups__isnull=True) - | Q(groups__permit_department__is_integrator_admin=True), - ) - .exclude() - .distinct() - ) - integrator_administrative_entities_list = ( - models.AdministrativeEntity.objects.associated_to_user(user).values_list( - "pk", flat=True - ) - ) - # Used to remove anonymous users from the list + # Remove anonymous users if flag at true or user is not a superuser anonymous_users = [] - for user in qs: - if remove_anonymous and user.userprofile.is_anonymous: - anonymous_users.append(user.pk) - elif ( - user.userprofile.is_anonymous - and user.userprofile.administrative_entity.pk - not in integrator_administrative_entities_list - ): - anonymous_users.append(user.pk) - qs = qs.exclude(pk__in=anonymous_users) + if remove_anonymous or not user.is_superuser: + for qs_user in qs: + if qs_user.userprofile.is_anonymous: + anonymous_users.append(qs_user.pk) + qs = qs.exclude(pk__in=anonymous_users) return qs diff --git a/geocity/apps/accounts/views.py b/geocity/apps/accounts/views.py index 5404a9c52..8f14ec81d 100644 --- a/geocity/apps/accounts/views.py +++ b/geocity/apps/accounts/views.py @@ -26,7 +26,7 @@ check_mandatory_2FA, permanent_user_required, ) -from geocity.fields import PrivateFileSystemStorage +from geocity.fields import PrivateFileSystemStorage, PublicFileSystemStorage from . import forms, models from .users import is_2FA_mandatory @@ -403,3 +403,17 @@ def administrative_entity_file_download(request, path): return StreamingHttpResponse(storage.open(path), content_type=mime_type) except IOError: raise Http404 + + +def site_profile_custom_image(request, path): + """ + Serve public image for template customization + """ + + mime_type, encoding = mimetypes.guess_type(path) + storage = PublicFileSystemStorage() + + try: + return StreamingHttpResponse(storage.open(path), content_type=mime_type) + except IOError: + raise Http404 diff --git a/geocity/apps/api/pagination.py b/geocity/apps/api/pagination.py index b83707d64..15000e7d4 100644 --- a/geocity/apps/api/pagination.py +++ b/geocity/apps/api/pagination.py @@ -25,8 +25,9 @@ class AgendaResultsSetPagination(PageNumberPagination): max_page_size = 100 def get_paginated_response(self, data): - domain = self.request.GET.get("domain") - agenda_filters = get_available_filters_for_agenda_as_json(domain) + domains = self.request.GET.get("domain") + domains = domains.split(",") if domains else None + agenda_filters = get_available_filters_for_agenda_as_json(domains) return Response( { "type": "FeatureCollection", diff --git a/geocity/apps/api/serializers.py b/geocity/apps/api/serializers.py index b3fb9351b..3a1bf4f2f 100644 --- a/geocity/apps/api/serializers.py +++ b/geocity/apps/api/serializers.py @@ -951,14 +951,16 @@ def get_agenda_form_fields(value, detailed, available_filters): return result -def get_available_filters_for_agenda_as_qs(domain): +def get_available_filters_for_agenda_as_qs(domains): """ Returns a list of filters available for a specific entity. The order is important, agenda-embed has no logic, everything is set here """ - if not domain: + if not domains or len(domains) > 1: return None + else: + domain = domains[0] entity = ( AdministrativeEntity.objects.filter( @@ -982,30 +984,50 @@ def get_available_filters_for_agenda_as_qs(domain): return available_filters -def get_available_filters_for_agenda_as_json(domain): +def get_available_filters_for_agenda_as_json(domains): """ Returns the list of filters for api """ - available_filters = get_available_filters_for_agenda_as_qs(domain) - - if not available_filters: - return None - + available_filters = get_available_filters_for_agenda_as_qs(domains) agenda_filters = [] - for available_filter in available_filters: - actual_filter = { - "label": available_filter.name, - "slug": available_filter.api_name, + + # Category filter available for simple and detailed agenda. Example : Sport, Culture, Économie, etc... + if domains and len(domains) > 1: + domain_filter = { + "label": "Catégorie", + "slug": "domain_filter", } - actual_filter["options"] = [ + entities = AdministrativeEntity.objects.filter( + forms__agenda_visible=True, forms__is_public=True, tags__name__in=domains + ) + domain_filter["options"] = [ { - "id": key, - "label": choice.strip(), + "id": entity.id, + "label": entity.agenda_name + if entity.agenda_name + else "Valeur non définie", } - for key, choice in enumerate(available_filter.choices.strip().splitlines()) + for entity in entities ] - agenda_filters.append(actual_filter) - return agenda_filters + agenda_filters.append(domain_filter) + + if available_filters: + for available_filter in available_filters: + actual_filter = { + "label": available_filter.name, + "slug": available_filter.api_name, + } + actual_filter["options"] = [ + { + "id": key, + "label": choice.strip(), + } + for key, choice in enumerate( + available_filter.choices.strip().splitlines() + ) + ] + agenda_filters.append(actual_filter) + return agenda_filters if agenda_filters != [] else None class AgendaSerializer(serializers.Serializer): diff --git a/geocity/apps/api/views.py b/geocity/apps/api/views.py index ed102a54e..6ff5ad08f 100644 --- a/geocity/apps/api/views.py +++ b/geocity/apps/api/views.py @@ -546,6 +546,23 @@ def image_thumbor_display(request, submission_id, image_name): return thumbor_response +def get_agenda_submissions(entities, submissions): + # To validate a request and show it in agenda, an user need to be pilot of his own entity and validator for other entities. + # Retrieve pilots of entity + pilot_of_entity = User.objects.filter( + groups__permit_department__administrative_entity__in=entities, + groups__permit_department__is_backoffice=True, + ).values("id") + + # Check agenda submissions is validated by any user on the pilot group of it's own entity + submissions = submissions.filter( + Q(administrative_entity__in=entities) + | Q(validations__validated_by__in=pilot_of_entity) + ) + + return submissions + + class AgendaViewSet(viewsets.ReadOnlyModelViewSet): """ This api provides : @@ -554,7 +571,7 @@ class AgendaViewSet(viewsets.ReadOnlyModelViewSet): Submissions are filtered by date Images are provided through thumbor https://thumbor.readthedocs.io/en/latest/imaging.html Arguments that can be supplied in the url : - - ?domain can be given through the component in html, it corresponds to the entity tags (mots-clés) + - ?domain can be given through the component in html, can be a comma separated list, it corresponds to the entity tags (mots-clés) - ?starts_at - ?ends_at - ?width @@ -577,7 +594,7 @@ def get_queryset(self): This view has a detailed result and a simple result The detailed result is built with AgendaResultsSetPagination, this is required to be able to make pagination and return features and filters - The simple result just return informations for une submission + The simple result just return informations for une submission and lists available domains The order is important, agenda-embed has no logic, everything is set here """ submissions = ( @@ -594,26 +611,18 @@ def get_queryset(self): # List params given by the request as query_params query_params = self.request.query_params # Filter domain (administrative_entity) to permit sites to filter on their own domain (e.g.: sports, culture) - domain = None - - if "domain" in query_params: - domain = query_params["domain"] - entity = AdministrativeEntity.objects.filter( - tags__name=domain - ).first() # get can return an error - - # To validate a request and show it in agenda, an user need to be pilot of his own entity and validator for other entities. - # Retrieve pilots of entity - pilot_of_entity = User.objects.filter( - groups__permit_department__administrative_entity=entity, - groups__permit_department__is_backoffice=True, - ).values("id") - - # Check agenda submissions is validated by any user on the pilot group of it's own entity - submissions = submissions.filter( - Q(administrative_entity=entity) - | Q(validations__validated_by__in=pilot_of_entity) - ) + domains = None + + if "domain_filter" in query_params: + domain_filter = query_params.getlist("domain_filter") + entities = AdministrativeEntity.objects.filter(id__in=domain_filter) + submissions = get_agenda_submissions(entities, submissions) + + elif "domain" in query_params: + domains = query_params["domain"] + domains = domains.split(",") if domains else None + entities = AdministrativeEntity.objects.filter(tags__name__in=domains) + submissions = get_agenda_submissions(entities, submissions) if "starts_at" in query_params: starts_at = datetime.datetime.strptime( @@ -641,8 +650,9 @@ def get_queryset(self): Q(selected_forms__field_values__value__val__contains=query) | Q(selected_forms__field_values__value__val__icontains=query) ) + # List every available filter - available_filters = serializers.get_available_filters_for_agenda_as_qs(domain) + available_filters = serializers.get_available_filters_for_agenda_as_qs(domains) if not available_filters: return submissions diff --git a/geocity/apps/core/templates/base_generic.html b/geocity/apps/core/templates/base_generic.html index e5b2887b9..b9802b7ac 100644 --- a/geocity/apps/core/templates/base_generic.html +++ b/geocity/apps/core/templates/base_generic.html @@ -102,11 +102,8 @@
- + {% block printbutton %} + {% endblock %}
  • {}
  • ", url, ff.form.name) - ) - list_html = "\n".join(list_content) - return f"" - else: - return "—" + return self.instance.get_form_list() def clean_file_download(self): if self.cleaned_data["input_type"] == "file_download": @@ -658,6 +653,7 @@ class FieldAdmin(IntegratorFilterMixin, admin.ModelAdmin): "input_type", "placeholder", "help_text", + "form_list", ] list_filter = [ "name", @@ -706,6 +702,11 @@ class FieldAdmin(IntegratorFilterMixin, admin.ModelAdmin): ), ) + def form_list(self, obj): + return obj.get_form_list() + + form_list.short_description = _("Formulaires") + def sortable_str(self, obj): sortable_str = ( obj.__str__()[:25] + "..." if len(obj.__str__()) > 25 else obj.__str__() diff --git a/geocity/apps/forms/models.py b/geocity/apps/forms/models.py index b53b4f8bf..c2603f591 100644 --- a/geocity/apps/forms/models.py +++ b/geocity/apps/forms/models.py @@ -1072,3 +1072,19 @@ def clean(self): ) else: self.api_name = convert_string_to_api_key(self.name) + + def get_form_list(self): + forms_fields = self.form_fields.all().order_by("form__name", "form__id") + + if forms_fields: + list_content = [] + for ff in forms_fields: + url = reverse( + "admin:forms_form_change", kwargs={"object_id": ff.form.id} + ) + + list_content.append(f"
  • {ff.form.name}
  • ") + list_html = "\n".join(list_content) + return mark_safe(f"") + else: + return "-" diff --git a/geocity/apps/submissions/forms.py b/geocity/apps/submissions/forms.py index cfdc63f0a..903150034 100644 --- a/geocity/apps/submissions/forms.py +++ b/geocity/apps/submissions/forms.py @@ -2357,8 +2357,16 @@ def get_submission_forms(submission): appendices_by_object_type = dict(appendices_form.get_form_fields_by_form()) amend_custom_fields_values = submission.get_amend_custom_fields_values() amend_custom_properties_by_object_type = defaultdict(list) + amend_properties_visible_by_author = False + amend_properties_visible_by_validators = False + for value in amend_custom_fields_values: amend_custom_properties_by_object_type[value.form.form].append(value) + if value.field.is_visible_by_author: + amend_properties_visible_by_author = True + if value.field.is_visible_by_validators: + amend_properties_visible_by_validators = True + forms_infos = [ ( selected_form.form, @@ -2368,8 +2376,11 @@ def get_submission_forms(submission): ) for selected_form in submission.selected_forms.all() ] - - return forms_infos + return ( + forms_infos, + amend_properties_visible_by_author, + amend_properties_visible_by_validators, + ) class SubmissionValidationsForm(forms.ModelForm): diff --git a/geocity/apps/submissions/management/commands/fixturize.py b/geocity/apps/submissions/management/commands/fixturize.py index 8f12d452e..7a9cebffd 100644 --- a/geocity/apps/submissions/management/commands/fixturize.py +++ b/geocity/apps/submissions/management/commands/fixturize.py @@ -725,7 +725,7 @@ def setup_homepage(self, entities, iterations): application_description_css = """