diff --git a/.github/workflows/impress-frontend.yml b/.github/workflows/impress-frontend.yml index 2df8692e..bb29b838 100644 --- a/.github/workflows/impress-frontend.yml +++ b/.github/workflows/impress-frontend.yml @@ -19,6 +19,8 @@ jobs: test-front: needs: install-dependencies runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout repository uses: actions/checkout@v4 @@ -41,6 +43,8 @@ jobs: lint-front: runs-on: ubuntu-latest needs: install-dependencies + permissions: + contents: read steps: - name: Checkout repository uses: actions/checkout@v4 @@ -193,3 +197,38 @@ jobs: strip-hash: "[-_.][a-f0-9]{8,}(?=\\.(?:js|css|html)$)" omit-unchanged: true install-script: "yarn install --frozen-lockfile" + + uikit-theme-checker: + runs-on: ubuntu-latest + needs: install-dependencies + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22.x" + - name: Restore the frontend cache + uses: actions/cache@v4 + with: + path: "src/frontend/**/node_modules" + key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }} + fail-on-cache-miss: true + + - name: Build theme + run: cd src/frontend/apps/impress && yarn build-theme + + - name: Ensure theme is up to date + shell: bash + run: | + if [[ -n "$(git status --porcelain)" ]]; then + echo "Error: build-theme produced git changes (tracked or untracked)." + echo "--- git status --porcelain ---" + git status --porcelain + echo "--- git diff ---" + git --no-pager diff + exit 1 + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bf01eaf..c3a179bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,9 @@ and this project adheres to ### Added - ✨(frontend) integrate configurable Waffle #1795 - -### Fixed - -- ✅(e2e) fix e2e test for other browsers #1799 -- 🐛(frontend) add fallback for unsupported Blocknote languages #1810 -- 🐛(frontend) fix emojipicker closing in tree #1808 +- ✨ Import of documents #1609 +- 🚨(CI) gives warning if theme not updated #1811 +- 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825 ### Changed @@ -22,11 +19,23 @@ and this project adheres to - ♿️(frontend) fix subdoc opening and emoji pick focus #1745 - ✨(backend) add field for button label in email template #1817 +### Fixed + +- ✅(e2e) fix e2e test for other browsers #1799 +- 🐛(export) fix export column NaN #1819 +- 🐛(frontend) add fallback for unsupported Blocknote languages #1810 +- 🐛(frontend) fix emojipicker closing in tree #1808 +- 🐛(frontend) display children in favorite #1782 +- 🐛(frontend) preserve typed text after @ on escape #1833 + +### Removed + +- 🔥(project) remove all code related to template #1780 + ### Security - 🔒️(trivy) fix vulnerability about jaraco.context #1806 - ## [4.4.0] - 2026-01-13 ### Added diff --git a/Makefile b/Makefile index c0ea3b59..13319999 100644 --- a/Makefile +++ b/Makefile @@ -213,6 +213,7 @@ logs: ## display app-dev logs (follow mode) .PHONY: logs run-backend: ## Start only the backend application and all needed services + @$(COMPOSE) up --force-recreate -d docspec @$(COMPOSE) up --force-recreate -d celery-dev @$(COMPOSE) up --force-recreate -d y-provider-development @$(COMPOSE) up --force-recreate -d nginx diff --git a/bin/Tiltfile b/bin/Tiltfile index 4ddc284b..edcea0a7 100644 --- a/bin/Tiltfile +++ b/bin/Tiltfile @@ -8,6 +8,7 @@ docker_build( dockerfile='../Dockerfile', only=['./src/backend', './src/mail', './docker'], target = 'backend-production', + build_args={'DOCKER_USER': '1000:1000'}, live_update=[ sync('../src/backend', '/app'), run( @@ -23,6 +24,7 @@ docker_build( dockerfile='../src/frontend/servers/y-provider/Dockerfile', only=['./src/frontend/', './docker/', './.dockerignore'], target = 'y-provider', + build_args={'DOCKER_USER': '1000:1000'}, live_update=[ sync('../src/frontend/servers/y-provider/src', '/home/frontend/servers/y-provider/src'), ] @@ -34,6 +36,7 @@ docker_build( dockerfile='../src/frontend/Dockerfile', only=['./src/frontend', './docker', './.dockerignore'], target = 'impress', + build_args={'DOCKER_USER': '1000:1000'}, live_update=[ sync('../src/frontend', '/home/frontend'), ] diff --git a/compose.yml b/compose.yml index cb5905c3..c1d5be85 100644 --- a/compose.yml +++ b/compose.yml @@ -231,6 +231,11 @@ services: condition: service_healthy restart: true + docspec: + image: ghcr.io/docspecio/api:2.6.3 + ports: + - "4000:4000" + networks: lasuite: name: lasuite-network diff --git a/docs/env.md b/docs/env.md index 6576f332..186cfb30 100644 --- a/docs/env.md +++ b/docs/env.md @@ -32,6 +32,8 @@ These are the environment variables you can set for the `impress-backend` contai | CONVERSION_API_ENDPOINT | Conversion API endpoint | convert | | CONVERSION_API_SECURE | Require secure conversion api | false | | CONVERSION_API_TIMEOUT | Conversion api timeout | 30 | +| CONVERSION_FILE_MAX_SIZE | The file max size allowed when uploaded to convert it | 20971520 (20MB) | +| CONVERSION_FILE_EXTENSIONS_ALLOWED | Extension list managed by the conversion service | [".docx", ".md"] | CRISP_WEBSITE_ID | Crisp website id for support | | | DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 | | DB_HOST | Host of the database | localhost | @@ -54,10 +56,12 @@ These are the environment variables you can set for the `impress-backend` contai | DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | | | DJANGO_EMAIL_LOGO_IMG | Logo for the email | | | DJANGO_EMAIL_PORT | Port used to connect to email host | | +| DJANGO_EMAIL_URL_APP | Url used in the email to go to the app | | | DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false | | DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false | | DJANGO_SECRET_KEY | Secret key | | | DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] | +| DOCSPEC_API_URL | URL to endpoint of DocSpec conversion API | | | DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 | | FRONTEND_CSS_URL | To add a external css file to the app | | | FRONTEND_JS_URL | To add a external js file to the app | | diff --git a/docs/examples/helm/impress.values.yaml b/docs/examples/helm/impress.values.yaml index ce2fd8b2..57fab24c 100644 --- a/docs/examples/helm/impress.values.yaml +++ b/docs/examples/helm/impress.values.yaml @@ -27,6 +27,7 @@ backend: DJANGO_EMAIL_HOST: "mailcatcher" DJANGO_EMAIL_LOGO_IMG: https://docs.127.0.0.1.nip.io/assets/logo-suite-numerique.png DJANGO_EMAIL_PORT: 1025 + DJANGO_EMAIL_URL_APP: https://docs.127.0.0.1.nip.io DJANGO_EMAIL_USE_SSL: False LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR LOGGING_LEVEL_LOGGERS_ROOT: INFO diff --git a/docs/installation/compose.md b/docs/installation/compose.md index a0a65a8f..f8bb49a9 100644 --- a/docs/installation/compose.md +++ b/docs/installation/compose.md @@ -127,6 +127,7 @@ DJANGO_EMAIL_FROM= DJANGO_EMAIL_BRAND_NAME= # e.g. "La Suite Numérique" DJANGO_EMAIL_LOGO_IMG= # e.g. "https://docs.yourdomain.tld/assets/logo-suite-numerique.png" +DJANGO_EMAIL_URL_APP= # e.g. "https://docs.yourdomain.tld" ``` ### AI diff --git a/env.d/development/common b/env.d/development/common index f1a70811..7f8b1311 100644 --- a/env.d/development/common +++ b/env.d/development/common @@ -20,6 +20,7 @@ DJANGO_EMAIL_BRAND_NAME="La Suite Numérique" DJANGO_EMAIL_HOST="mailcatcher" DJANGO_EMAIL_LOGO_IMG="http://localhost:3000/assets/logo-suite-numerique.png" DJANGO_EMAIL_PORT=1025 +DJANGO_EMAIL_URL_APP="http://localhost:3000" # Backend url IMPRESS_BASE_URL="http://localhost:8072" @@ -76,6 +77,8 @@ DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/ Y_PROVIDER_API_KEY=yprovider-api-key +DOCSPEC_API_URL=http://docspec:4000/conversion + # Theme customization THEME_CUSTOMIZATION_CACHE_TIMEOUT=15 diff --git a/env.d/development/common.e2e b/env.d/development/common.e2e index 15434a68..6394c8b2 100644 --- a/env.d/development/common.e2e +++ b/env.d/development/common.e2e @@ -6,4 +6,4 @@ Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/ # Throttle API_DOCUMENT_THROTTLE_RATE=1000/min -API_CONFIG_THROTTLE_RATE=1000/min \ No newline at end of file +API_CONFIG_THROTTLE_RATE=1000/min diff --git a/env.d/production.dist/backend b/env.d/production.dist/backend index c4f70f86..1016bd3f 100644 --- a/env.d/production.dist/backend +++ b/env.d/production.dist/backend @@ -24,7 +24,8 @@ DJANGO_EMAIL_FROM= #DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending. DJANGO_EMAIL_BRAND_NAME="La Suite Numérique" -DJANGO_EMAIL_LOGO_IMG="https://${DOCS_HOST}/assets/logo-suite-numerique.png" +DJANGO_EMAIL_LOGO_IMG="https://${DOCS_HOST}/assets/logo-suite-numerique.png" +DJANGO_EMAIL_URL_APP="https://${DOCS_HOST}" # Media AWS_S3_ENDPOINT_URL=https://${S3_HOST} diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index 40bf32ec..964e57be 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -11,14 +11,6 @@ from core import models from core.tasks.user_reconciliation import user_reconciliation_csv_import_job -class TemplateAccessInline(admin.TabularInline): - """Inline admin class for template accesses.""" - - autocomplete_fields = ["user"] - model = models.TemplateAccess - extra = 0 - - @admin.register(models.User) class UserAdmin(auth_admin.UserAdmin): """Admin class for the User model""" @@ -71,7 +63,6 @@ class UserAdmin(auth_admin.UserAdmin): }, ), ) - inlines = (TemplateAccessInline,) list_display = ( "id", "sub", @@ -179,7 +170,7 @@ class TemplateAdmin(admin.ModelAdmin): class DocumentAccessInline(admin.TabularInline): - """Inline admin class for template accesses.""" + """Inline admin class for document accesses.""" autocomplete_fields = ["user"] model = models.DocumentAccess diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index 29df311c..affd9b07 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -98,10 +98,10 @@ class CanCreateInvitationPermission(permissions.BasePermission): class ResourceWithAccessPermission(permissions.BasePermission): - """A permission class for templates and invitations.""" + """A permission class for invitations.""" def has_permission(self, request, view): - """check create permission for templates.""" + """check create permission.""" return request.user.is_authenticated or view.action != "create" def has_object_permission(self, request, view, obj): diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 6c224513..349e0191 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -4,6 +4,7 @@ import binascii import mimetypes from base64 import b64decode +from os.path import splitext from django.conf import settings from django.db.models import Q @@ -15,10 +16,11 @@ import magic from rest_framework import serializers from core import choices, enums, models, utils, validators +from core.services import mime_types from core.services.ai_services import AI_ACTIONS from core.services.converter_services import ( ConversionError, - YdocConverter, + Converter, ) @@ -59,30 +61,6 @@ class UserLightSerializer(UserSerializer): read_only_fields = ["full_name", "short_name"] -class TemplateAccessSerializer(serializers.ModelSerializer): - """Serialize template accesses.""" - - abilities = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = models.TemplateAccess - resource_field_name = "template" - fields = ["id", "user", "team", "role", "abilities"] - read_only_fields = ["id", "abilities"] - - def get_abilities(self, instance) -> dict: - """Return abilities of the logged-in user on the instance.""" - request = self.context.get("request") - if request: - return instance.get_abilities(request.user) - return {} - - def update(self, instance, validated_data): - """Make "user" field is readonly but only on update.""" - validated_data.pop("user", None) - return super().update(instance, validated_data) - - class ListDocumentSerializer(serializers.ModelSerializer): """Serialize documents with limited fields for display in lists.""" @@ -188,6 +166,9 @@ class DocumentSerializer(ListDocumentSerializer): content = serializers.CharField(required=False) websocket = serializers.BooleanField(required=False, write_only=True) + file = serializers.FileField( + required=False, write_only=True, allow_null=True, max_length=255 + ) class Meta: model = models.Document @@ -204,6 +185,7 @@ class DocumentSerializer(ListDocumentSerializer): "deleted_at", "depth", "excerpt", + "file", "is_favorite", "link_role", "link_reach", @@ -273,6 +255,30 @@ class DocumentSerializer(ListDocumentSerializer): return value + def validate_file(self, file): + """Add file size and type constraints as defined in settings.""" + if not file: + return None + + # Validate file size + if file.size > settings.CONVERSION_FILE_MAX_SIZE: + max_size = settings.CONVERSION_FILE_MAX_SIZE // (1024 * 1024) + raise serializers.ValidationError( + f"File size exceeds the maximum limit of {max_size:d} MB." + ) + + _name, extension = splitext(file.name) + + if extension.lower() not in settings.CONVERSION_FILE_EXTENSIONS_ALLOWED: + raise serializers.ValidationError( + ( + f"File extension {extension} is not allowed. Allowed extensions" + f" are: {settings.CONVERSION_FILE_EXTENSIONS_ALLOWED}." + ) + ) + + return file + def save(self, **kwargs): """ Process the content field to extract attachment keys and update the document's @@ -461,7 +467,9 @@ class ServerCreateDocumentSerializer(serializers.Serializer): language = user.language or language try: - document_content = YdocConverter().convert(validated_data["content"]) + document_content = Converter().convert( + validated_data["content"], mime_types.MARKDOWN, mime_types.YJS + ) except ConversionError as err: raise serializers.ValidationError( {"content": ["Could not convert content"]} @@ -660,52 +668,6 @@ class FileUploadSerializer(serializers.Serializer): return attrs -class TemplateSerializer(serializers.ModelSerializer): - """Serialize templates.""" - - abilities = serializers.SerializerMethodField(read_only=True) - accesses = TemplateAccessSerializer(many=True, read_only=True) - - class Meta: - model = models.Template - fields = [ - "id", - "title", - "accesses", - "abilities", - "css", - "code", - "is_public", - ] - read_only_fields = ["id", "accesses", "abilities"] - - def get_abilities(self, document) -> dict: - """Return abilities of the logged-in user on the instance.""" - request = self.context.get("request") - if request: - return document.get_abilities(request.user) - return {} - - -# pylint: disable=abstract-method -class DocumentGenerationSerializer(serializers.Serializer): - """Serializer to receive a request to generate a document on a template.""" - - body = serializers.CharField(label=_("Body")) - body_type = serializers.ChoiceField( - choices=["html", "markdown"], - label=_("Body type"), - required=False, - default="html", - ) - format = serializers.ChoiceField( - choices=["pdf", "docx"], - label=_("Format"), - required=False, - default="pdf", - ) - - class InvitationSerializer(serializers.ModelSerializer): """Serialize invitations.""" diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 66b4021b..1f74d146 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -45,17 +45,19 @@ from rest_framework.views import APIView from core import authentication, choices, enums, models from core.api.filters import remove_accents +from core.services import mime_types from core.services.ai_services import AIService from core.services.collaboration_services import CollaborationService +from core.services.converter_services import ( + ConversionError, + Converter, +) from core.services.converter_services import ( ServiceUnavailableError as YProviderServiceUnavailableError, ) from core.services.converter_services import ( ValidationError as YProviderValidationError, ) -from core.services.converter_services import ( - YdocConverter, -) from core.services.search_indexers import ( get_document_indexer, get_visited_document_ids_of, @@ -582,6 +584,28 @@ class DocumentViewSet( "IN SHARE ROW EXCLUSIVE MODE;" ) + # Remove file from validated_data as it's not a model field + # Process it if present + uploaded_file = serializer.validated_data.pop("file", None) + + # If a file is uploaded, convert it to Yjs format and set as content + if uploaded_file: + try: + file_content = uploaded_file.read() + + converter = Converter() + converted_content = converter.convert( + file_content, + content_type=uploaded_file.content_type, + accept=mime_types.YJS, + ) + serializer.validated_data["content"] = converted_content + serializer.validated_data["title"] = uploaded_file.name + except ConversionError as err: + raise drf.exceptions.ValidationError( + {"file": ["Could not convert file content"]} + ) from err + obj = models.Document.add_root( creator=self.request.user, **serializer.validated_data, @@ -683,12 +707,29 @@ class DocumentViewSet( """Get list of favorite documents for the current user.""" user = request.user + queryset = self.get_queryset() + + # Among the results, we may have documents that are ancestors/descendants + # of each other. In this case we want to keep only the highest ancestors. + root_paths = utils.filter_root_paths( + queryset.order_by("path").values_list("path", flat=True), + skip_sorting=True, + ) + + path_list = db.Q() + for path in root_paths: + path_list |= db.Q(path__startswith=path) + favorite_documents_ids = models.DocumentFavorite.objects.filter( user=user ).values_list("document_id", flat=True) - queryset = self.filter_queryset(self.get_queryset()) + queryset = self.queryset.filter(path_list) queryset = queryset.filter(id__in=favorite_documents_ids) + queryset = queryset.annotate_user_roles(user) + queryset = queryset.annotate( + is_favorite=db.Value(True, output_field=db.BooleanField()) + ) return self.get_response_for_queryset(queryset) @drf.decorators.action( @@ -1919,14 +1960,14 @@ class DocumentViewSet( if base64_content is not None: # Convert using the y-provider service try: - yprovider = YdocConverter() + yprovider = Converter() result = yprovider.convert( base64.b64decode(base64_content), - "application/vnd.yjs.doc", + mime_types.YJS, { - "markdown": "text/markdown", - "html": "text/html", - "json": "application/json", + "markdown": mime_types.MARKDOWN, + "html": mime_types.HTML, + "json": mime_types.JSON, }[content_format], ) content = result @@ -2147,64 +2188,6 @@ class DocumentAccessViewSet( ) -class TemplateViewSet( - drf.mixins.RetrieveModelMixin, - viewsets.GenericViewSet, -): - """Template ViewSet""" - - filter_backends = [drf.filters.OrderingFilter] - permission_classes = [ - permissions.IsAuthenticatedOrSafe, - permissions.ResourceWithAccessPermission, - ] - throttle_scope = "template" - ordering = ["-created_at"] - ordering_fields = ["created_at", "updated_at", "title"] - serializer_class = serializers.TemplateSerializer - queryset = models.Template.objects.all() - - def get_queryset(self): - """Custom queryset to get user related templates.""" - queryset = super().get_queryset() - user = self.request.user - - if not user.is_authenticated: - return queryset - - user_roles_query = ( - models.TemplateAccess.objects.filter( - db.Q(user=user) | db.Q(team__in=user.teams), - template_id=db.OuterRef("pk"), - ) - .values("template") - .annotate(roles_array=ArrayAgg("role")) - .values("roles_array") - ) - return queryset.annotate(user_roles=db.Subquery(user_roles_query)).distinct() - - def list(self, request, *args, **kwargs): - """Restrict templates returned by the list endpoint""" - queryset = self.filter_queryset(self.get_queryset()) - user = self.request.user - if user.is_authenticated: - queryset = queryset.filter( - db.Q(accesses__user=user) - | db.Q(accesses__team__in=user.teams) - | db.Q(is_public=True) - ) - else: - queryset = queryset.filter(is_public=True) - - page = self.paginate_queryset(queryset) - if page is not None: - serializer = self.get_serializer(page, many=True) - return self.get_paginated_response(serializer.data) - - serializer = self.get_serializer(queryset, many=True) - return drf.response.Response(serializer.data) - - class InvitationViewset( drf.mixins.CreateModelMixin, drf.mixins.ListModelMixin, @@ -2412,6 +2395,8 @@ class ConfigView(drf.views.APIView): "AI_FEATURE_ENABLED", "COLLABORATION_WS_URL", "COLLABORATION_WS_NOT_CONNECTED_READY_ONLY", + "CONVERSION_FILE_EXTENSIONS_ALLOWED", + "CONVERSION_FILE_MAX_SIZE", "CRISP_WEBSITE_ID", "ENVIRONMENT", "FRONTEND_CSS_URL", diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index c0737cdc..acc39e06 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -53,15 +53,6 @@ class UserFactory(factory.django.DjangoModelFactory): if create and (extracted is True): UserDocumentAccessFactory(user=self, role="owner") - @factory.post_generation - def with_owned_template(self, create, extracted, **kwargs): - """ - Create a template for which the user is owner to check - that there is no interference - """ - if create and (extracted is True): - UserTemplateAccessFactory(user=self, role="owner") - class ParentNodeFactory(factory.declarations.ParameteredAttribute): """Custom factory attribute for setting the parent node.""" @@ -202,50 +193,6 @@ class DocumentAskForAccessFactory(factory.django.DjangoModelFactory): role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices]) -class TemplateFactory(factory.django.DjangoModelFactory): - """A factory to create templates""" - - class Meta: - model = models.Template - django_get_or_create = ("title",) - skip_postgeneration_save = True - - title = factory.Sequence(lambda n: f"template{n}") - is_public = factory.Faker("boolean") - - @factory.post_generation - def users(self, create, extracted, **kwargs): - """Add users to template from a given list of users with or without roles.""" - if create and extracted: - for item in extracted: - if isinstance(item, models.User): - UserTemplateAccessFactory(template=self, user=item) - else: - UserTemplateAccessFactory(template=self, user=item[0], role=item[1]) - - -class UserTemplateAccessFactory(factory.django.DjangoModelFactory): - """Create fake template user accesses for testing.""" - - class Meta: - model = models.TemplateAccess - - template = factory.SubFactory(TemplateFactory) - user = factory.SubFactory(UserFactory) - role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices]) - - -class TeamTemplateAccessFactory(factory.django.DjangoModelFactory): - """Create fake template team accesses for testing.""" - - class Meta: - model = models.TemplateAccess - - template = factory.SubFactory(TemplateFactory) - team = factory.Sequence(lambda n: f"team{n}") - role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices]) - - class InvitationFactory(factory.django.DjangoModelFactory): """A factory to create invitations for a user""" diff --git a/src/backend/core/models.py b/src/backend/core/models.py index e327a8c8..46aa4b96 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1098,7 +1098,7 @@ class Document(MP_Node, BaseModel): def send_email(self, subject, emails, context=None, language=None): """Generate and send email from a template.""" context = context or {} - domain = Site.objects.get_current().domain + domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain language = language or get_language() context.update( { @@ -1711,163 +1711,6 @@ class Reaction(BaseModel): return f"Reaction {self.emoji} on comment {self.comment.id}" -class Template(BaseModel): - """HTML and CSS code used for formatting the print around the MarkDown body.""" - - title = models.CharField(_("title"), max_length=255) - description = models.TextField(_("description"), blank=True) - code = models.TextField(_("code"), blank=True) - css = models.TextField(_("css"), blank=True) - is_public = models.BooleanField( - _("public"), - default=False, - help_text=_("Whether this template is public for anyone to use."), - ) - - class Meta: - db_table = "impress_template" - ordering = ("title",) - verbose_name = _("Template") - verbose_name_plural = _("Templates") - - def __str__(self): - return self.title - - def get_role(self, user): - """Return the roles a user has on a resource as an iterable.""" - if not user.is_authenticated: - return None - - try: - roles = self.user_roles or [] - except AttributeError: - try: - roles = self.accesses.filter( - models.Q(user=user) | models.Q(team__in=user.teams), - ).values_list("role", flat=True) - except (models.ObjectDoesNotExist, IndexError): - roles = [] - - return RoleChoices.max(*roles) - - def get_abilities(self, user): - """ - Compute and return abilities for a given user on the template. - """ - role = self.get_role(user) - is_owner_or_admin = role in PRIVILEGED_ROLES - can_get = self.is_public or bool(role) - can_update = is_owner_or_admin or role == RoleChoices.EDITOR - - return { - "destroy": role == RoleChoices.OWNER, - "generate_document": can_get, - "accesses_manage": is_owner_or_admin, - "update": can_update, - "partial_update": can_update, - "retrieve": can_get, - } - - -class TemplateAccess(BaseAccess): - """Relation model to give access to a template for a user or a team with a role.""" - - template = models.ForeignKey( - Template, - on_delete=models.CASCADE, - related_name="accesses", - ) - - class Meta: - db_table = "impress_template_access" - ordering = ("-created_at",) - verbose_name = _("Template/user relation") - verbose_name_plural = _("Template/user relations") - constraints = [ - models.UniqueConstraint( - fields=["user", "template"], - condition=models.Q(user__isnull=False), # Exclude null users - name="unique_template_user", - violation_error_message=_("This user is already in this template."), - ), - models.UniqueConstraint( - fields=["team", "template"], - condition=models.Q(team__gt=""), # Exclude empty string teams - name="unique_template_team", - violation_error_message=_("This team is already in this template."), - ), - models.CheckConstraint( - condition=models.Q(user__isnull=False, team="") - | models.Q(user__isnull=True, team__gt=""), - name="check_template_access_either_user_or_team", - violation_error_message=_("Either user or team must be set, not both."), - ), - ] - - def __str__(self): - return f"{self.user!s} is {self.role:s} in template {self.template!s}" - - def get_role(self, user): - """ - Get the role a user has on a resource. - """ - if not user.is_authenticated: - return None - - try: - roles = self.user_roles or [] - except AttributeError: - teams = user.teams - try: - roles = self.template.accesses.filter( - models.Q(user=user) | models.Q(team__in=teams), - ).values_list("role", flat=True) - except (Template.DoesNotExist, IndexError): - roles = [] - - return RoleChoices.max(*roles) - - def get_abilities(self, user): - """ - Compute and return abilities for a given user on the template access. - """ - role = self.get_role(user) - is_owner_or_admin = role in PRIVILEGED_ROLES - - if self.role == RoleChoices.OWNER: - can_delete = (role == RoleChoices.OWNER) and self.template.accesses.filter( - role=RoleChoices.OWNER - ).count() > 1 - set_role_to = ( - [RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER] - if can_delete - else [] - ) - else: - can_delete = is_owner_or_admin - set_role_to = [] - if role == RoleChoices.OWNER: - set_role_to.append(RoleChoices.OWNER) - if is_owner_or_admin: - set_role_to.extend( - [RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER] - ) - - # Remove the current role as we don't want to propose it as an option - try: - set_role_to.remove(self.role) - except ValueError: - pass - - return { - "destroy": can_delete, - "update": bool(set_role_to), - "partial_update": bool(set_role_to), - "retrieve": bool(role), - "set_role_to": set_role_to, - } - - class Invitation(BaseModel): """User invitation to a document.""" diff --git a/src/backend/core/services/converter_services.py b/src/backend/core/services/converter_services.py index 9c79a719..46c289ba 100644 --- a/src/backend/core/services/converter_services.py +++ b/src/backend/core/services/converter_services.py @@ -1,11 +1,17 @@ """Y-Provider API services.""" +import logging +import typing from base64 import b64encode from django.conf import settings import requests +from core.services import mime_types + +logger = logging.getLogger(__name__) + class ConversionError(Exception): """Base exception for conversion-related errors.""" @@ -19,8 +25,81 @@ class ServiceUnavailableError(ConversionError): """Raised when the conversion service is unavailable.""" +class ConverterProtocol(typing.Protocol): + """Protocol for converter classes.""" + + def convert(self, data, content_type, accept): + """Convert content from one format to another.""" + + +class Converter: + """Orchestrates conversion between different formats using specialized converters.""" + + docspec: ConverterProtocol + ydoc: ConverterProtocol + + def __init__(self): + self.docspec = DocSpecConverter() + self.ydoc = YdocConverter() + + def convert(self, data, content_type, accept): + """Convert input into other formats using external microservices.""" + + if content_type == mime_types.DOCX and accept == mime_types.YJS: + blocknote_data = self.docspec.convert( + data, mime_types.DOCX, mime_types.BLOCKNOTE + ) + return self.ydoc.convert( + blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS + ) + + return self.ydoc.convert(data, content_type, accept) + + +class DocSpecConverter: + """Service class for DocSpec conversion-related operations.""" + + def _request(self, url, data, content_type): + """Make a request to the DocSpec API.""" + + response = requests.post( + url, + headers={"Accept": mime_types.BLOCKNOTE}, + files={"file": ("document.docx", data, content_type)}, + timeout=settings.CONVERSION_API_TIMEOUT, + verify=settings.CONVERSION_API_SECURE, + ) + if not response.ok: + logger.error( + "DocSpec API error: url=%s, status=%d, response=%s", + url, + response.status_code, + response.text[:200] if response.text else "empty", + ) + response.raise_for_status() + return response + + def convert(self, data, content_type, accept): + """Convert a Document to BlockNote.""" + if not data: + raise ValidationError("Input data cannot be empty") + + if content_type != mime_types.DOCX or accept != mime_types.BLOCKNOTE: + raise ValidationError( + f"Conversion from {content_type} to {accept} is not supported." + ) + + try: + return self._request(settings.DOCSPEC_API_URL, data, content_type).content + except requests.RequestException as err: + logger.exception("DocSpec service error: url=%s", settings.DOCSPEC_API_URL) + raise ServiceUnavailableError( + "Failed to connect to DocSpec conversion service", + ) from err + + class YdocConverter: - """Service class for conversion-related operations.""" + """Service class for YDoc conversion-related operations.""" @property def auth_header(self): @@ -41,32 +120,34 @@ class YdocConverter: timeout=settings.CONVERSION_API_TIMEOUT, verify=settings.CONVERSION_API_SECURE, ) + if not response.ok: + logger.error( + "Y-Provider API error: url=%s, status=%d, response=%s", + url, + response.status_code, + response.text[:200] if response.text else "empty", + ) response.raise_for_status() return response - def convert( - self, text, content_type="text/markdown", accept="application/vnd.yjs.doc" - ): + def convert(self, data, content_type=mime_types.MARKDOWN, accept=mime_types.YJS): """Convert a Markdown text into our internal format using an external microservice.""" - if not text: - raise ValidationError("Input text cannot be empty") + if not data: + raise ValidationError("Input data cannot be empty") + url = f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/" try: - response = self._request( - f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/", - text, - content_type, - accept, - ) - if accept == "application/vnd.yjs.doc": + response = self._request(url, data, content_type, accept) + if accept == mime_types.YJS: return b64encode(response.content).decode("utf-8") - if accept in {"text/markdown", "text/html"}: + if accept in {mime_types.MARKDOWN, "text/html"}: return response.text - if accept == "application/json": + if accept == mime_types.JSON: return response.json() raise ValidationError("Unsupported format") except requests.RequestException as err: + logger.exception("Y-Provider service error: url=%s", url) raise ServiceUnavailableError( - "Failed to connect to conversion service", + f"Failed to connect to YDoc conversion service {content_type}, {accept}", ) from err diff --git a/src/backend/core/templates/core/generate_document.html b/src/backend/core/templates/core/generate_document.html deleted file mode 100644 index ac9f2915..00000000 --- a/src/backend/core/templates/core/generate_document.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - Generate Document - - -

Generate Document

-
- {% csrf_token %} - {{ form.as_p }} - -
- - diff --git a/src/backend/core/templatetags/extra_tags.py b/src/backend/core/templatetags/extra_tags.py index 109bd7b0..273459ff 100644 --- a/src/backend/core/templatetags/extra_tags.py +++ b/src/backend/core/templatetags/extra_tags.py @@ -1,4 +1,4 @@ -"""Custom template tags for the core application of People.""" +"""Custom template tags for the core application of Docs.""" import base64 diff --git a/src/backend/core/tests/documents/test_api_documents_create_for_owner.py b/src/backend/core/tests/documents/test_api_documents_create_for_owner.py index 346fe407..f2944038 100644 --- a/src/backend/core/tests/documents/test_api_documents_create_for_owner.py +++ b/src/backend/core/tests/documents/test_api_documents_create_for_owner.py @@ -16,6 +16,7 @@ from rest_framework.test import APIClient from core import factories from core.api.serializers import ServerCreateDocumentSerializer from core.models import Document, Invitation, User +from core.services import mime_types from core.services.converter_services import ConversionError, YdocConverter pytestmark = pytest.mark.django_db @@ -191,7 +192,9 @@ def test_api_documents_create_for_owner_existing(mock_convert_md): assert response.status_code == 201 - mock_convert_md.assert_called_once_with("Document content") + mock_convert_md.assert_called_once_with( + "Document content", mime_types.MARKDOWN, mime_types.YJS + ) document = Document.objects.get() assert response.json() == {"id": str(document.id)} @@ -236,7 +239,9 @@ def test_api_documents_create_for_owner_new_user(mock_convert_md): assert response.status_code == 201 - mock_convert_md.assert_called_once_with("Document content") + mock_convert_md.assert_called_once_with( + "Document content", mime_types.MARKDOWN, mime_types.YJS + ) document = Document.objects.get() assert response.json() == {"id": str(document.id)} @@ -297,7 +302,9 @@ def test_api_documents_create_for_owner_existing_user_email_no_sub_with_fallback assert response.status_code == 201 - mock_convert_md.assert_called_once_with("Document content") + mock_convert_md.assert_called_once_with( + "Document content", mime_types.MARKDOWN, mime_types.YJS + ) document = Document.objects.get() assert response.json() == {"id": str(document.id)} @@ -393,7 +400,9 @@ def test_api_documents_create_for_owner_new_user_no_sub_no_fallback_allow_duplic HTTP_AUTHORIZATION="Bearer DummyToken", ) assert response.status_code == 201 - mock_convert_md.assert_called_once_with("Document content") + mock_convert_md.assert_called_once_with( + "Document content", mime_types.MARKDOWN, mime_types.YJS + ) document = Document.objects.get() assert response.json() == {"id": str(document.id)} @@ -474,7 +483,9 @@ def test_api_documents_create_for_owner_with_default_language( ) assert response.status_code == 201 - mock_convert_md.assert_called_once_with("Document content") + mock_convert_md.assert_called_once_with( + "Document content", mime_types.MARKDOWN, mime_types.YJS + ) assert mock_send.call_args[0][3] == "de-de" @@ -501,7 +512,9 @@ def test_api_documents_create_for_owner_with_custom_language(mock_convert_md): assert response.status_code == 201 - mock_convert_md.assert_called_once_with("Document content") + mock_convert_md.assert_called_once_with( + "Document content", mime_types.MARKDOWN, mime_types.YJS + ) assert len(mail.outbox) == 1 email = mail.outbox[0] @@ -537,7 +550,9 @@ def test_api_documents_create_for_owner_with_custom_subject_and_message( assert response.status_code == 201 - mock_convert_md.assert_called_once_with("Document content") + mock_convert_md.assert_called_once_with( + "Document content", mime_types.MARKDOWN, mime_types.YJS + ) assert len(mail.outbox) == 1 email = mail.outbox[0] @@ -571,7 +586,9 @@ def test_api_documents_create_for_owner_with_converter_exception( format="json", HTTP_AUTHORIZATION="Bearer DummyToken", ) - mock_convert_md.assert_called_once_with("Document content") + mock_convert_md.assert_called_once_with( + "Document content", mime_types.MARKDOWN, mime_types.YJS + ) assert response.status_code == 400 assert response.json() == {"content": ["Could not convert content"]} diff --git a/src/backend/core/tests/documents/test_api_documents_favorite_list.py b/src/backend/core/tests/documents/test_api_documents_favorite_list.py index f93e95e0..d5bfe3c1 100644 --- a/src/backend/core/tests/documents/test_api_documents_favorite_list.py +++ b/src/backend/core/tests/documents/test_api_documents_favorite_list.py @@ -83,3 +83,34 @@ def test_api_document_favorite_list_authenticated_with_favorite(): } ], } + + +def test_api_document_favorite_list_with_favorite_children(): + """Authenticated users should receive their favorite documents, including children.""" + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + root = factories.DocumentFactory(creator=user, users=[user]) + children = factories.DocumentFactory.create_batch( + 2, parent=root, favorited_by=[user] + ) + + access = factories.UserDocumentAccessFactory( + user=user, role=models.RoleChoices.READER, document__favorited_by=[user] + ) + + other_root = factories.DocumentFactory(creator=user, users=[user]) + factories.DocumentFactory.create_batch(2, parent=other_root) + + response = client.get("/api/v1.0/documents/favorite_list/") + + assert response.status_code == 200 + assert response.json()["count"] == 3 + + content = response.json()["results"] + + assert content[0]["id"] == str(children[0].id) + assert content[1]["id"] == str(children[1].id) + assert content[2]["id"] == str(access.document.id) diff --git a/src/backend/core/tests/templates/test_api_templates_create.py b/src/backend/core/tests/templates/test_api_templates_create.py deleted file mode 100644 index 3935d0ac..00000000 --- a/src/backend/core/tests/templates/test_api_templates_create.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -Tests for Templates API endpoint in impress's core app: create -""" - -import pytest -from rest_framework.test import APIClient - -from core import factories -from core.models import Template - -pytestmark = pytest.mark.django_db - - -def test_api_templates_create_anonymous(): - """Anonymous users should not be allowed to create templates.""" - response = APIClient().post( - "/api/v1.0/templates/", - { - "title": "my template", - }, - ) - - assert response.status_code == 401 - assert not Template.objects.exists() - - -def test_api_templates_create_authenticated(): - """ - Authenticated users should be able to create templates and should automatically be declared - as the owner of the newly created template. - """ - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - response = client.post( - "/api/v1.0/templates/", - { - "title": "my template", - }, - format="json", - ) - - assert response.status_code == 405 - assert not Template.objects.exists() diff --git a/src/backend/core/tests/templates/test_api_templates_delete.py b/src/backend/core/tests/templates/test_api_templates_delete.py deleted file mode 100644 index 8db834de..00000000 --- a/src/backend/core/tests/templates/test_api_templates_delete.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -Tests for Templates API endpoint in impress's core app: delete -""" - -import random - -import pytest -from rest_framework.test import APIClient - -from core import factories, models - -pytestmark = pytest.mark.django_db - - -def test_api_templates_delete_anonymous(): - """Anonymous users should not be allowed to destroy a template.""" - template = factories.TemplateFactory() - - response = APIClient().delete( - f"/api/v1.0/templates/{template.id!s}/", - ) - - assert response.status_code == 401 - assert models.Template.objects.count() == 1 - - -def test_api_templates_delete_not_implemented(): - """ - Authenticated users should not be allowed to delete a template to which they are not - related. - """ - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - is_public = random.choice([True, False]) - template = factories.TemplateFactory(is_public=is_public, users=[(user, "owner")]) - - response = client.delete( - f"/api/v1.0/templates/{template.id!s}/", - ) - - assert response.status_code == 405 - assert models.Template.objects.count() == 1 diff --git a/src/backend/core/tests/templates/test_api_templates_list.py b/src/backend/core/tests/templates/test_api_templates_list.py deleted file mode 100644 index f4980354..00000000 --- a/src/backend/core/tests/templates/test_api_templates_list.py +++ /dev/null @@ -1,237 +0,0 @@ -""" -Tests for Templates API endpoint in impress's core app: list -""" - -from unittest import mock - -import pytest -from rest_framework.pagination import PageNumberPagination -from rest_framework.test import APIClient - -from core import factories - -pytestmark = pytest.mark.django_db - - -def test_api_templates_list_anonymous(): - """Anonymous users should only be able to list public templates.""" - factories.TemplateFactory.create_batch(2, is_public=False) - public_templates = factories.TemplateFactory.create_batch(2, is_public=True) - expected_ids = {str(template.id) for template in public_templates} - - response = APIClient().get("/api/v1.0/templates/") - - assert response.status_code == 200 - results = response.json()["results"] - assert len(results) == 2 - results_id = {result["id"] for result in results} - assert expected_ids == results_id - - -def test_api_templates_list_authenticated_direct(): - """ - Authenticated users should be able to list templates they are a direct - owner/administrator/member of or that are public. - """ - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - related_templates = [ - access.template - for access in factories.UserTemplateAccessFactory.create_batch(5, user=user) - ] - public_templates = factories.TemplateFactory.create_batch(2, is_public=True) - factories.TemplateFactory.create_batch(2, is_public=False) - - expected_ids = { - str(template.id) for template in related_templates + public_templates - } - - response = client.get( - "/api/v1.0/templates/", - ) - - assert response.status_code == 200 - results = response.json()["results"] - assert len(results) == 7 - results_id = {result["id"] for result in results} - assert expected_ids == results_id - - -def test_api_templates_list_authenticated_via_team(mock_user_teams): - """ - Authenticated users should be able to list templates they are a - owner/administrator/member of via a team or that are public. - """ - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - mock_user_teams.return_value = ["team1", "team2", "unknown"] - - templates_team1 = [ - access.template - for access in factories.TeamTemplateAccessFactory.create_batch(2, team="team1") - ] - templates_team2 = [ - access.template - for access in factories.TeamTemplateAccessFactory.create_batch(3, team="team2") - ] - public_templates = factories.TemplateFactory.create_batch(2, is_public=True) - factories.TemplateFactory.create_batch(2, is_public=False) - - expected_ids = { - str(template.id) - for template in templates_team1 + templates_team2 + public_templates - } - - response = client.get("/api/v1.0/templates/") - - assert response.status_code == 200 - results = response.json()["results"] - assert len(results) == 7 - results_id = {result["id"] for result in results} - assert expected_ids == results_id - - -@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2) -def test_api_templates_list_pagination( - _mock_page_size, -): - """Pagination should work as expected.""" - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - template_ids = [ - str(access.template_id) - for access in factories.UserTemplateAccessFactory.create_batch(3, user=user) - ] - - # Get page 1 - response = client.get( - "/api/v1.0/templates/", - ) - - assert response.status_code == 200 - content = response.json() - - assert content["count"] == 3 - assert content["next"] == "http://testserver/api/v1.0/templates/?page=2" - assert content["previous"] is None - - assert len(content["results"]) == 2 - for item in content["results"]: - template_ids.remove(item["id"]) - - # Get page 2 - response = client.get( - "/api/v1.0/templates/?page=2", - ) - - assert response.status_code == 200 - content = response.json() - - assert content["count"] == 3 - assert content["next"] is None - assert content["previous"] == "http://testserver/api/v1.0/templates/" - - assert len(content["results"]) == 1 - template_ids.remove(content["results"][0]["id"]) - assert template_ids == [] - - -def test_api_templates_list_authenticated_distinct(): - """A template with several related users should only be listed once.""" - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - other_user = factories.UserFactory() - - template = factories.TemplateFactory(users=[user, other_user], is_public=True) - - response = client.get( - "/api/v1.0/templates/", - ) - - assert response.status_code == 200 - content = response.json() - assert len(content["results"]) == 1 - assert content["results"][0]["id"] == str(template.id) - - -def test_api_templates_list_order_default(): - """The templates list should be sorted by 'created_at' in descending order by default.""" - user = factories.UserFactory() - client = APIClient() - client.force_login(user) - - template_ids = [ - str(access.template.id) - for access in factories.UserTemplateAccessFactory.create_batch(5, user=user) - ] - - response = client.get( - "/api/v1.0/templates/", - ) - - assert response.status_code == 200 - - response_data = response.json() - response_template_ids = [template["id"] for template in response_data["results"]] - - template_ids.reverse() - assert response_template_ids == template_ids, ( - "created_at values are not sorted from newest to oldest" - ) - - -def test_api_templates_list_order_param(): - """ - The templates list is sorted by 'created_at' in ascending order when setting - the "ordering" query parameter. - """ - user = factories.UserFactory() - client = APIClient() - client.force_login(user) - - templates_ids = [ - str(access.template.id) - for access in factories.UserTemplateAccessFactory.create_batch(5, user=user) - ] - - response = client.get( - "/api/v1.0/templates/?ordering=created_at", - ) - assert response.status_code == 200 - - response_data = response.json() - - response_template_ids = [template["id"] for template in response_data["results"]] - - assert response_template_ids == templates_ids, ( - "created_at values are not sorted from oldest to newest" - ) - - -def test_api_template_throttling(settings): - """Test api template throttling.""" - current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"] - settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"] = "2/minute" - client = APIClient() - for _i in range(2): - response = client.get("/api/v1.0/templates/") - assert response.status_code == 200 - with mock.patch("core.api.throttling.capture_message") as mock_capture_message: - response = client.get("/api/v1.0/templates/") - assert response.status_code == 429 - mock_capture_message.assert_called_once_with( - "Rate limit exceeded for scope template", "warning" - ) - settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"] = current_rate diff --git a/src/backend/core/tests/templates/test_api_templates_retrieve.py b/src/backend/core/tests/templates/test_api_templates_retrieve.py deleted file mode 100644 index e3466ab2..00000000 --- a/src/backend/core/tests/templates/test_api_templates_retrieve.py +++ /dev/null @@ -1,522 +0,0 @@ -""" -Tests for Templates API endpoint in impress's core app: retrieve -""" - -import pytest -from rest_framework.test import APIClient - -from core import factories - -pytestmark = pytest.mark.django_db - - -def test_api_templates_retrieve_anonymous_public(): - """Anonymous users should be allowed to retrieve public templates.""" - template = factories.TemplateFactory(is_public=True) - - response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/") - - assert response.status_code == 200 - assert response.json() == { - "id": str(template.id), - "abilities": { - "destroy": False, - "generate_document": True, - "accesses_manage": False, - "partial_update": False, - "retrieve": True, - "update": False, - }, - "accesses": [], - "title": template.title, - "is_public": True, - "code": template.code, - "css": template.css, - } - - -def test_api_templates_retrieve_anonymous_not_public(): - """Anonymous users should not be able to retrieve a template that is not public.""" - template = factories.TemplateFactory(is_public=False) - - response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/") - - assert response.status_code == 401 - assert response.json() == { - "detail": "Authentication credentials were not provided." - } - - -def test_api_templates_retrieve_authenticated_unrelated_public(): - """ - Authenticated users should be able to retrieve a public template to which they are - not related. - """ - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - template = factories.TemplateFactory(is_public=True) - - response = client.get( - f"/api/v1.0/templates/{template.id!s}/", - ) - assert response.status_code == 200 - assert response.json() == { - "id": str(template.id), - "abilities": { - "destroy": False, - "generate_document": True, - "accesses_manage": False, - "partial_update": False, - "retrieve": True, - "update": False, - }, - "accesses": [], - "title": template.title, - "is_public": True, - "code": template.code, - "css": template.css, - } - - -def test_api_templates_retrieve_authenticated_unrelated_not_public(): - """ - Authenticated users should not be allowed to retrieve a template that is not public and - to which they are not related. - """ - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - template = factories.TemplateFactory(is_public=False) - - response = client.get( - f"/api/v1.0/templates/{template.id!s}/", - ) - assert response.status_code == 403 - assert response.json() == { - "detail": "You do not have permission to perform this action." - } - - -def test_api_templates_retrieve_authenticated_related_direct(): - """ - Authenticated users should be allowed to retrieve a template to which they - are directly related whatever the role. - """ - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - template = factories.TemplateFactory() - access1 = factories.UserTemplateAccessFactory(template=template, user=user) - access2 = factories.UserTemplateAccessFactory(template=template) - - response = client.get( - f"/api/v1.0/templates/{template.id!s}/", - ) - assert response.status_code == 200 - content = response.json() - assert sorted(content.pop("accesses"), key=lambda x: x["user"]) == sorted( - [ - { - "id": str(access1.id), - "user": str(user.id), - "team": "", - "role": access1.role, - "abilities": access1.get_abilities(user), - }, - { - "id": str(access2.id), - "user": str(access2.user.id), - "team": "", - "role": access2.role, - "abilities": access2.get_abilities(user), - }, - ], - key=lambda x: x["user"], - ) - assert response.json() == { - "id": str(template.id), - "title": template.title, - "abilities": template.get_abilities(user), - "is_public": template.is_public, - "code": template.code, - "css": template.css, - } - - -def test_api_templates_retrieve_authenticated_related_team_none(mock_user_teams): - """ - Authenticated users should not be able to retrieve a template related to teams in - which the user is not. - """ - mock_user_teams.return_value = [] - - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - template = factories.TemplateFactory(is_public=False) - - factories.TeamTemplateAccessFactory( - template=template, team="readers", role="reader" - ) - factories.TeamTemplateAccessFactory( - template=template, team="editors", role="editor" - ) - factories.TeamTemplateAccessFactory( - template=template, team="administrators", role="administrator" - ) - factories.TeamTemplateAccessFactory(template=template, team="owners", role="owner") - factories.TeamTemplateAccessFactory(template=template) - factories.TeamTemplateAccessFactory() - - response = client.get(f"/api/v1.0/templates/{template.id!s}/") - assert response.status_code == 403 - assert response.json() == { - "detail": "You do not have permission to perform this action." - } - - -@pytest.mark.parametrize( - "teams", - [ - ["readers"], - ["unknown", "readers"], - ["editors"], - ["unknown", "editors"], - ], -) -def test_api_templates_retrieve_authenticated_related_team_readers_or_editors( - teams, mock_user_teams -): - """ - Authenticated users should be allowed to retrieve a template to which they - are related via a team whatever the role and see all its accesses. - """ - mock_user_teams.return_value = teams - - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - template = factories.TemplateFactory(is_public=False) - - access_reader = factories.TeamTemplateAccessFactory( - template=template, team="readers", role="reader" - ) - access_editor = factories.TeamTemplateAccessFactory( - template=template, team="editors", role="editor" - ) - access_administrator = factories.TeamTemplateAccessFactory( - template=template, team="administrators", role="administrator" - ) - access_owner = factories.TeamTemplateAccessFactory( - template=template, team="owners", role="owner" - ) - other_access = factories.TeamTemplateAccessFactory(template=template) - factories.TeamTemplateAccessFactory() - - response = client.get(f"/api/v1.0/templates/{template.id!s}/") - assert response.status_code == 200 - content = response.json() - expected_abilities = { - "destroy": False, - "retrieve": True, - "set_role_to": [], - "update": False, - "partial_update": False, - } - assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted( - [ - { - "id": str(access_reader.id), - "user": None, - "team": "readers", - "role": access_reader.role, - "abilities": expected_abilities, - }, - { - "id": str(access_editor.id), - "user": None, - "team": "editors", - "role": access_editor.role, - "abilities": expected_abilities, - }, - { - "id": str(access_administrator.id), - "user": None, - "team": "administrators", - "role": access_administrator.role, - "abilities": expected_abilities, - }, - { - "id": str(access_owner.id), - "user": None, - "team": "owners", - "role": access_owner.role, - "abilities": expected_abilities, - }, - { - "id": str(other_access.id), - "user": None, - "team": other_access.team, - "role": other_access.role, - "abilities": expected_abilities, - }, - ], - key=lambda x: x["id"], - ) - assert response.json() == { - "id": str(template.id), - "title": template.title, - "abilities": template.get_abilities(user), - "is_public": False, - "code": template.code, - "css": template.css, - } - - -@pytest.mark.parametrize( - "teams", - [ - ["administrators"], - ["members", "administrators"], - ["unknown", "administrators"], - ], -) -def test_api_templates_retrieve_authenticated_related_team_administrators( - teams, mock_user_teams -): - """ - Authenticated users should be allowed to retrieve a template to which they - are related via a team whatever the role and see all its accesses. - """ - mock_user_teams.return_value = teams - - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - template = factories.TemplateFactory(is_public=False) - - access_reader = factories.TeamTemplateAccessFactory( - template=template, team="readers", role="reader" - ) - access_editor = factories.TeamTemplateAccessFactory( - template=template, team="editors", role="editor" - ) - access_administrator = factories.TeamTemplateAccessFactory( - template=template, team="administrators", role="administrator" - ) - access_owner = factories.TeamTemplateAccessFactory( - template=template, team="owners", role="owner" - ) - other_access = factories.TeamTemplateAccessFactory(template=template) - factories.TeamTemplateAccessFactory() - - response = client.get(f"/api/v1.0/templates/{template.id!s}/") - - assert response.status_code == 200 - content = response.json() - assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted( - [ - { - "id": str(access_reader.id), - "user": None, - "team": "readers", - "role": "reader", - "abilities": { - "destroy": True, - "retrieve": True, - "set_role_to": ["administrator", "editor"], - "update": True, - "partial_update": True, - }, - }, - { - "id": str(access_editor.id), - "user": None, - "team": "editors", - "role": "editor", - "abilities": { - "destroy": True, - "retrieve": True, - "set_role_to": ["administrator", "reader"], - "update": True, - "partial_update": True, - }, - }, - { - "id": str(access_administrator.id), - "user": None, - "team": "administrators", - "role": "administrator", - "abilities": { - "destroy": True, - "retrieve": True, - "set_role_to": ["editor", "reader"], - "update": True, - "partial_update": True, - }, - }, - { - "id": str(access_owner.id), - "user": None, - "team": "owners", - "role": "owner", - "abilities": { - "destroy": False, - "retrieve": True, - "set_role_to": [], - "update": False, - "partial_update": False, - }, - }, - { - "id": str(other_access.id), - "user": None, - "team": other_access.team, - "role": other_access.role, - "abilities": other_access.get_abilities(user), - }, - ], - key=lambda x: x["id"], - ) - assert response.json() == { - "id": str(template.id), - "title": template.title, - "abilities": template.get_abilities(user), - "is_public": False, - "code": template.code, - "css": template.css, - } - - -@pytest.mark.parametrize( - "teams", - [ - ["owners"], - ["owners", "administrators"], - ["members", "administrators", "owners"], - ["unknown", "owners"], - ], -) -def test_api_templates_retrieve_authenticated_related_team_owners( - teams, mock_user_teams -): - """ - Authenticated users should be allowed to retrieve a template to which they - are related via a team whatever the role and see all its accesses. - """ - mock_user_teams.return_value = teams - - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - template = factories.TemplateFactory(is_public=False) - - access_reader = factories.TeamTemplateAccessFactory( - template=template, team="readers", role="reader" - ) - access_editor = factories.TeamTemplateAccessFactory( - template=template, team="editors", role="editor" - ) - access_administrator = factories.TeamTemplateAccessFactory( - template=template, team="administrators", role="administrator" - ) - access_owner = factories.TeamTemplateAccessFactory( - template=template, team="owners", role="owner" - ) - other_access = factories.TeamTemplateAccessFactory(template=template) - factories.TeamTemplateAccessFactory() - - response = client.get(f"/api/v1.0/templates/{template.id!s}/") - - assert response.status_code == 200 - content = response.json() - assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted( - [ - { - "id": str(access_reader.id), - "user": None, - "team": "readers", - "role": "reader", - "abilities": { - "destroy": True, - "retrieve": True, - "set_role_to": ["owner", "administrator", "editor"], - "update": True, - "partial_update": True, - }, - }, - { - "id": str(access_editor.id), - "user": None, - "team": "editors", - "role": "editor", - "abilities": { - "destroy": True, - "retrieve": True, - "set_role_to": ["owner", "administrator", "reader"], - "update": True, - "partial_update": True, - }, - }, - { - "id": str(access_administrator.id), - "user": None, - "team": "administrators", - "role": "administrator", - "abilities": { - "destroy": True, - "retrieve": True, - "set_role_to": ["owner", "editor", "reader"], - "update": True, - "partial_update": True, - }, - }, - { - "id": str(access_owner.id), - "user": None, - "team": "owners", - "role": "owner", - "abilities": { - # editable only if there is another owner role than the user's team... - "destroy": other_access.role == "owner", - "retrieve": True, - "set_role_to": ["administrator", "editor", "reader"] - if other_access.role == "owner" - else [], - "update": other_access.role == "owner", - "partial_update": other_access.role == "owner", - }, - }, - { - "id": str(other_access.id), - "user": None, - "team": other_access.team, - "role": other_access.role, - "abilities": other_access.get_abilities(user), - }, - ], - key=lambda x: x["id"], - ) - assert response.json() == { - "id": str(template.id), - "title": template.title, - "abilities": template.get_abilities(user), - "is_public": False, - "code": template.code, - "css": template.css, - } diff --git a/src/backend/core/tests/templates/test_api_templates_update.py b/src/backend/core/tests/templates/test_api_templates_update.py deleted file mode 100644 index a2040293..00000000 --- a/src/backend/core/tests/templates/test_api_templates_update.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -Tests for Templates API endpoint in impress's core app: update -""" - -import pytest -from rest_framework.test import APIClient - -from core import factories -from core.api import serializers - -pytestmark = pytest.mark.django_db - - -def test_api_templates_update_anonymous(): - """Anonymous users should not be allowed to update a template.""" - template = factories.TemplateFactory() - - new_template_values = serializers.TemplateSerializer( - instance=factories.TemplateFactory() - ).data - response = APIClient().put( - f"/api/v1.0/templates/{template.id!s}/", - new_template_values, - format="json", - ) - assert response.status_code == 401 - - -def test_api_templates_update_not_implemented(): - """ - Authenticated users should not be allowed to update a template. - """ - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - template = factories.TemplateFactory(users=[(user, "owner")]) - - new_template_values = serializers.TemplateSerializer( - instance=factories.TemplateFactory() - ).data - - response = client.put( - f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json" - ) - - assert response.status_code == 405 - - response = client.patch( - f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json" - ) - - assert response.status_code == 405 diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index e29187b3..ac3a9b30 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -46,6 +46,8 @@ def test_api_config(is_authenticated): "AI_FEATURE_ENABLED": False, "COLLABORATION_WS_URL": "http://testcollab/", "COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True, + "CONVERSION_FILE_EXTENSIONS_ALLOWED": [".docx", ".md"], + "CONVERSION_FILE_MAX_SIZE": 20971520, "CRISP_WEBSITE_ID": "123", "ENVIRONMENT": "test", "FRONTEND_CSS_URL": "http://testcss/", diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index 521b07c1..e5432cdd 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -1024,6 +1024,39 @@ def test_models_documents__email_invitation__success(): assert f"docs/{document.id}/" in email_content +@pytest.mark.parametrize( + "email_url_app", + [ + "https://test-example.com", # Test with EMAIL_URL_APP set + None, # Test fallback to Site domain + ], +) +def test_models_documents__email_invitation__url_app_param(email_url_app): + """ + Test that email invitation uses EMAIL_URL_APP when set, or falls back to Site domain. + """ + with override_settings(EMAIL_URL_APP=email_url_app): + document = factories.DocumentFactory() + + sender = factories.UserFactory( + full_name="Test Sender", email="sender@example.com" + ) + document.send_invitation_email( + "guest@example.com", models.RoleChoices.EDITOR, sender, "en" + ) + + # pylint: disable-next=no-member + email = mail.outbox[0] + email_content = " ".join(email.body.split()) + + # Determine expected domain + if email_url_app: + assert f"https://test-example.com/docs/{document.id}/" in email_content + else: + # Default Site domain is example.com + assert f"example.com/docs/{document.id}/" in email_content + + def test_models_documents__email_invitation__success_empty_title(): """ The email invitation is sent successfully. diff --git a/src/backend/core/tests/test_models_template_accesses.py b/src/backend/core/tests/test_models_template_accesses.py deleted file mode 100644 index 70a24164..00000000 --- a/src/backend/core/tests/test_models_template_accesses.py +++ /dev/null @@ -1,419 +0,0 @@ -""" -Unit tests for the TemplateAccess model -""" - -from django.contrib.auth.models import AnonymousUser -from django.core.exceptions import ValidationError - -import pytest - -from core import factories - -pytestmark = pytest.mark.django_db - - -def test_models_template_accesses_str(): - """ - The str representation should include user email, template title and role. - """ - user = factories.UserFactory(email="david.bowman@example.com") - access = factories.UserTemplateAccessFactory( - role="reader", - user=user, - template__title="admins", - ) - assert str(access) == "david.bowman@example.com is reader in template admins" - - -def test_models_template_accesses_unique_user(): - """Template accesses should be unique for a given couple of user and template.""" - access = factories.UserTemplateAccessFactory() - - with pytest.raises( - ValidationError, - match="This user is already in this template.", - ): - factories.UserTemplateAccessFactory(user=access.user, template=access.template) - - -def test_models_template_accesses_several_empty_teams(): - """A template can have several template accesses with an empty team.""" - access = factories.UserTemplateAccessFactory() - factories.UserTemplateAccessFactory(template=access.template) - - -def test_models_template_accesses_unique_team(): - """Template accesses should be unique for a given couple of team and template.""" - access = factories.TeamTemplateAccessFactory() - - with pytest.raises( - ValidationError, - match="This team is already in this template.", - ): - factories.TeamTemplateAccessFactory(team=access.team, template=access.template) - - -def test_models_template_accesses_several_null_users(): - """A template can have several template accesses with a null user.""" - access = factories.TeamTemplateAccessFactory() - factories.TeamTemplateAccessFactory(template=access.template) - - -def test_models_template_accesses_user_and_team_set(): - """User and team can't both be set on a template access.""" - with pytest.raises( - ValidationError, - match="Either user or team must be set, not both.", - ): - factories.UserTemplateAccessFactory(team="my-team") - - -def test_models_template_accesses_user_and_team_empty(): - """User and team can't both be empty on a template access.""" - with pytest.raises( - ValidationError, - match="Either user or team must be set, not both.", - ): - factories.UserTemplateAccessFactory(user=None) - - -# get_abilities - - -def test_models_template_access_get_abilities_anonymous(): - """Check abilities returned for an anonymous user.""" - access = factories.UserTemplateAccessFactory() - abilities = access.get_abilities(AnonymousUser()) - assert abilities == { - "destroy": False, - "retrieve": False, - "update": False, - "partial_update": False, - "set_role_to": [], - } - - -def test_models_template_access_get_abilities_authenticated(): - """Check abilities returned for an authenticated user.""" - access = factories.UserTemplateAccessFactory() - user = factories.UserFactory() - abilities = access.get_abilities(user) - assert abilities == { - "destroy": False, - "retrieve": False, - "update": False, - "partial_update": False, - "set_role_to": [], - } - - -# - for owner - - -def test_models_template_access_get_abilities_for_owner_of_self_allowed(): - """ - Check abilities of self access for the owner of a template when - there is more than one owner left. - """ - access = factories.UserTemplateAccessFactory(role="owner") - factories.UserTemplateAccessFactory(template=access.template, role="owner") - abilities = access.get_abilities(access.user) - assert abilities == { - "destroy": True, - "retrieve": True, - "update": True, - "partial_update": True, - "set_role_to": ["administrator", "editor", "reader"], - } - - -def test_models_template_access_get_abilities_for_owner_of_self_last(): - """ - Check abilities of self access for the owner of a template when there is only one owner left. - """ - access = factories.UserTemplateAccessFactory(role="owner") - abilities = access.get_abilities(access.user) - assert abilities == { - "destroy": False, - "retrieve": True, - "update": False, - "partial_update": False, - "set_role_to": [], - } - - -def test_models_template_access_get_abilities_for_owner_of_owner(): - """Check abilities of owner access for the owner of a template.""" - access = factories.UserTemplateAccessFactory(role="owner") - factories.UserTemplateAccessFactory(template=access.template) # another one - user = factories.UserTemplateAccessFactory( - template=access.template, role="owner" - ).user - abilities = access.get_abilities(user) - assert abilities == { - "destroy": True, - "retrieve": True, - "update": True, - "partial_update": True, - "set_role_to": ["administrator", "editor", "reader"], - } - - -def test_models_template_access_get_abilities_for_owner_of_administrator(): - """Check abilities of administrator access for the owner of a template.""" - access = factories.UserTemplateAccessFactory(role="administrator") - factories.UserTemplateAccessFactory(template=access.template) # another one - user = factories.UserTemplateAccessFactory( - template=access.template, role="owner" - ).user - abilities = access.get_abilities(user) - assert abilities == { - "destroy": True, - "retrieve": True, - "update": True, - "partial_update": True, - "set_role_to": ["owner", "editor", "reader"], - } - - -def test_models_template_access_get_abilities_for_owner_of_editor(): - """Check abilities of editor access for the owner of a template.""" - access = factories.UserTemplateAccessFactory(role="editor") - factories.UserTemplateAccessFactory(template=access.template) # another one - user = factories.UserTemplateAccessFactory( - template=access.template, role="owner" - ).user - abilities = access.get_abilities(user) - assert abilities == { - "destroy": True, - "retrieve": True, - "update": True, - "partial_update": True, - "set_role_to": ["owner", "administrator", "reader"], - } - - -def test_models_template_access_get_abilities_for_owner_of_reader(): - """Check abilities of reader access for the owner of a template.""" - access = factories.UserTemplateAccessFactory(role="reader") - factories.UserTemplateAccessFactory(template=access.template) # another one - user = factories.UserTemplateAccessFactory( - template=access.template, role="owner" - ).user - abilities = access.get_abilities(user) - assert abilities == { - "destroy": True, - "retrieve": True, - "update": True, - "partial_update": True, - "set_role_to": ["owner", "administrator", "editor"], - } - - -# - for administrator - - -def test_models_template_access_get_abilities_for_administrator_of_owner(): - """Check abilities of owner access for the administrator of a template.""" - access = factories.UserTemplateAccessFactory(role="owner") - factories.UserTemplateAccessFactory(template=access.template) # another one - user = factories.UserTemplateAccessFactory( - template=access.template, role="administrator" - ).user - abilities = access.get_abilities(user) - assert abilities == { - "destroy": False, - "retrieve": True, - "update": False, - "partial_update": False, - "set_role_to": [], - } - - -def test_models_template_access_get_abilities_for_administrator_of_administrator(): - """Check abilities of administrator access for the administrator of a template.""" - access = factories.UserTemplateAccessFactory(role="administrator") - factories.UserTemplateAccessFactory(template=access.template) # another one - user = factories.UserTemplateAccessFactory( - template=access.template, role="administrator" - ).user - abilities = access.get_abilities(user) - assert abilities == { - "destroy": True, - "retrieve": True, - "update": True, - "partial_update": True, - "set_role_to": ["editor", "reader"], - } - - -def test_models_template_access_get_abilities_for_administrator_of_editor(): - """Check abilities of editor access for the administrator of a template.""" - access = factories.UserTemplateAccessFactory(role="editor") - factories.UserTemplateAccessFactory(template=access.template) # another one - user = factories.UserTemplateAccessFactory( - template=access.template, role="administrator" - ).user - abilities = access.get_abilities(user) - assert abilities == { - "destroy": True, - "retrieve": True, - "update": True, - "partial_update": True, - "set_role_to": ["administrator", "reader"], - } - - -def test_models_template_access_get_abilities_for_administrator_of_reader(): - """Check abilities of reader access for the administrator of a template.""" - access = factories.UserTemplateAccessFactory(role="reader") - factories.UserTemplateAccessFactory(template=access.template) # another one - user = factories.UserTemplateAccessFactory( - template=access.template, role="administrator" - ).user - abilities = access.get_abilities(user) - assert abilities == { - "destroy": True, - "retrieve": True, - "update": True, - "partial_update": True, - "set_role_to": ["administrator", "editor"], - } - - -# - For editor - - -def test_models_template_access_get_abilities_for_editor_of_owner(): - """Check abilities of owner access for the editor of a template.""" - access = factories.UserTemplateAccessFactory(role="owner") - factories.UserTemplateAccessFactory(template=access.template) # another one - user = factories.UserTemplateAccessFactory( - template=access.template, role="editor" - ).user - abilities = access.get_abilities(user) - assert abilities == { - "destroy": False, - "retrieve": True, - "update": False, - "partial_update": False, - "set_role_to": [], - } - - -def test_models_template_access_get_abilities_for_editor_of_administrator(): - """Check abilities of administrator access for the editor of a template.""" - access = factories.UserTemplateAccessFactory(role="administrator") - factories.UserTemplateAccessFactory(template=access.template) # another one - user = factories.UserTemplateAccessFactory( - template=access.template, role="editor" - ).user - abilities = access.get_abilities(user) - assert abilities == { - "destroy": False, - "retrieve": True, - "update": False, - "partial_update": False, - "set_role_to": [], - } - - -def test_models_template_access_get_abilities_for_editor_of_editor_user( - django_assert_num_queries, -): - """Check abilities of editor access for the editor of a template.""" - access = factories.UserTemplateAccessFactory(role="editor") - factories.UserTemplateAccessFactory(template=access.template) # another one - user = factories.UserTemplateAccessFactory( - template=access.template, role="editor" - ).user - - with django_assert_num_queries(1): - abilities = access.get_abilities(user) - - assert abilities == { - "destroy": False, - "retrieve": True, - "update": False, - "partial_update": False, - "set_role_to": [], - } - - -# - For reader - - -def test_models_template_access_get_abilities_for_reader_of_owner(): - """Check abilities of owner access for the reader of a template.""" - access = factories.UserTemplateAccessFactory(role="owner") - factories.UserTemplateAccessFactory(template=access.template) # another one - user = factories.UserTemplateAccessFactory( - template=access.template, role="reader" - ).user - abilities = access.get_abilities(user) - assert abilities == { - "destroy": False, - "retrieve": True, - "update": False, - "partial_update": False, - "set_role_to": [], - } - - -def test_models_template_access_get_abilities_for_reader_of_administrator(): - """Check abilities of administrator access for the reader of a template.""" - access = factories.UserTemplateAccessFactory(role="administrator") - factories.UserTemplateAccessFactory(template=access.template) # another one - user = factories.UserTemplateAccessFactory( - template=access.template, role="reader" - ).user - abilities = access.get_abilities(user) - assert abilities == { - "destroy": False, - "retrieve": True, - "update": False, - "partial_update": False, - "set_role_to": [], - } - - -def test_models_template_access_get_abilities_for_reader_of_reader_user( - django_assert_num_queries, -): - """Check abilities of reader access for the reader of a template.""" - access = factories.UserTemplateAccessFactory(role="reader") - factories.UserTemplateAccessFactory(template=access.template) # another one - user = factories.UserTemplateAccessFactory( - template=access.template, role="reader" - ).user - - with django_assert_num_queries(1): - abilities = access.get_abilities(user) - - assert abilities == { - "destroy": False, - "retrieve": True, - "update": False, - "partial_update": False, - "set_role_to": [], - } - - -def test_models_template_access_get_abilities_preset_role(django_assert_num_queries): - """No query is done if the role is preset, e.g., with a query annotation.""" - access = factories.UserTemplateAccessFactory(role="reader") - user = factories.UserTemplateAccessFactory( - template=access.template, role="reader" - ).user - access.user_roles = ["reader"] - - with django_assert_num_queries(0): - abilities = access.get_abilities(user) - - assert abilities == { - "destroy": False, - "retrieve": True, - "update": False, - "partial_update": False, - "set_role_to": [], - } diff --git a/src/backend/core/tests/test_models_templates.py b/src/backend/core/tests/test_models_templates.py deleted file mode 100644 index 95f8fbde..00000000 --- a/src/backend/core/tests/test_models_templates.py +++ /dev/null @@ -1,187 +0,0 @@ -""" -Unit tests for the Template model -""" - -from django.contrib.auth.models import AnonymousUser -from django.core.exceptions import ValidationError - -import pytest - -from core import factories, models - -pytestmark = pytest.mark.django_db - - -def test_models_templates_str(): - """The str representation should be the title of the template.""" - template = factories.TemplateFactory(title="admins") - assert str(template) == "admins" - - -def test_models_templates_id_unique(): - """The "id" field should be unique.""" - template = factories.TemplateFactory() - with pytest.raises(ValidationError, match="Template with this Id already exists."): - factories.TemplateFactory(id=template.id) - - -def test_models_templates_title_null(): - """The "title" field should not be null.""" - with pytest.raises(ValidationError, match="This field cannot be null."): - models.Template.objects.create(title=None) - - -def test_models_templates_title_empty(): - """The "title" field should not be empty.""" - with pytest.raises(ValidationError, match="This field cannot be blank."): - models.Template.objects.create(title="") - - -def test_models_templates_title_max_length(): - """The "title" field should be 100 characters maximum.""" - factories.TemplateFactory(title="a" * 255) - with pytest.raises( - ValidationError, - match=r"Ensure this value has at most 255 characters \(it has 256\)\.", - ): - factories.TemplateFactory(title="a" * 256) - - -# get_abilities - - -def test_models_templates_get_abilities_anonymous_public(): - """Check abilities returned for an anonymous user if the template is public.""" - template = factories.TemplateFactory(is_public=True) - abilities = template.get_abilities(AnonymousUser()) - assert abilities == { - "destroy": False, - "retrieve": True, - "update": False, - "accesses_manage": False, - "partial_update": False, - "generate_document": True, - } - - -def test_models_templates_get_abilities_anonymous_not_public(): - """Check abilities returned for an anonymous user if the template is private.""" - template = factories.TemplateFactory(is_public=False) - abilities = template.get_abilities(AnonymousUser()) - assert abilities == { - "destroy": False, - "retrieve": False, - "update": False, - "accesses_manage": False, - "partial_update": False, - "generate_document": False, - } - - -def test_models_templates_get_abilities_authenticated_public(): - """Check abilities returned for an authenticated user if the user is public.""" - template = factories.TemplateFactory(is_public=True) - abilities = template.get_abilities(factories.UserFactory()) - assert abilities == { - "destroy": False, - "retrieve": True, - "update": False, - "accesses_manage": False, - "partial_update": False, - "generate_document": True, - } - - -def test_models_templates_get_abilities_authenticated_not_public(): - """Check abilities returned for an authenticated user if the template is private.""" - template = factories.TemplateFactory(is_public=False) - abilities = template.get_abilities(factories.UserFactory()) - assert abilities == { - "destroy": False, - "retrieve": False, - "update": False, - "accesses_manage": False, - "partial_update": False, - "generate_document": False, - } - - -def test_models_templates_get_abilities_owner(): - """Check abilities returned for the owner of a template.""" - user = factories.UserFactory() - access = factories.UserTemplateAccessFactory(role="owner", user=user) - abilities = access.template.get_abilities(access.user) - assert abilities == { - "destroy": True, - "retrieve": True, - "update": True, - "accesses_manage": True, - "partial_update": True, - "generate_document": True, - } - - -def test_models_templates_get_abilities_administrator(): - """Check abilities returned for the administrator of a template.""" - access = factories.UserTemplateAccessFactory(role="administrator") - abilities = access.template.get_abilities(access.user) - assert abilities == { - "destroy": False, - "retrieve": True, - "update": True, - "accesses_manage": True, - "partial_update": True, - "generate_document": True, - } - - -def test_models_templates_get_abilities_editor_user(django_assert_num_queries): - """Check abilities returned for the editor of a template.""" - access = factories.UserTemplateAccessFactory(role="editor") - - with django_assert_num_queries(1): - abilities = access.template.get_abilities(access.user) - - assert abilities == { - "destroy": False, - "retrieve": True, - "update": True, - "accesses_manage": False, - "partial_update": True, - "generate_document": True, - } - - -def test_models_templates_get_abilities_reader_user(django_assert_num_queries): - """Check abilities returned for the reader of a template.""" - access = factories.UserTemplateAccessFactory(role="reader") - - with django_assert_num_queries(1): - abilities = access.template.get_abilities(access.user) - - assert abilities == { - "destroy": False, - "retrieve": True, - "update": False, - "accesses_manage": False, - "partial_update": False, - "generate_document": True, - } - - -def test_models_templates_get_abilities_preset_role(django_assert_num_queries): - """No query is done if the role is preset e.g. with query annotation.""" - access = factories.UserTemplateAccessFactory(role="reader") - access.template.user_roles = ["reader"] - - with django_assert_num_queries(0): - abilities = access.template.get_abilities(access.user) - - assert abilities == { - "destroy": False, - "retrieve": True, - "update": False, - "accesses_manage": False, - "partial_update": False, - "generate_document": True, - } diff --git a/src/backend/core/tests/test_services_converter_services.py b/src/backend/core/tests/test_services_converter_services.py index 086d132b..760504ce 100644 --- a/src/backend/core/tests/test_services_converter_services.py +++ b/src/backend/core/tests/test_services_converter_services.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch import pytest import requests +from core.services import mime_types from core.services.converter_services import ( ServiceUnavailableError, ValidationError, @@ -21,9 +22,9 @@ def test_auth_header(settings): def test_convert_empty_text(): - """Should raise ValidationError when text is empty.""" + """Should raise ValidationError when data is empty.""" converter = YdocConverter() - with pytest.raises(ValidationError, match="Input text cannot be empty"): + with pytest.raises(ValidationError, match="Input data cannot be empty"): converter.convert("") @@ -36,7 +37,7 @@ def test_convert_service_unavailable(mock_post): with pytest.raises( ServiceUnavailableError, - match="Failed to connect to conversion service", + match="Failed to connect to YDoc conversion service", ): converter.convert("test text") @@ -52,7 +53,7 @@ def test_convert_http_error(mock_post): with pytest.raises( ServiceUnavailableError, - match="Failed to connect to conversion service", + match="Failed to connect to YDoc conversion service", ): converter.convert("test text") @@ -83,8 +84,8 @@ def test_convert_full_integration(mock_post, settings): data="test markdown", headers={ "Authorization": "Bearer test-key", - "Content-Type": "text/markdown", - "Accept": "application/vnd.yjs.doc", + "Content-Type": mime_types.MARKDOWN, + "Accept": mime_types.YJS, }, timeout=5, verify=False, @@ -108,9 +109,7 @@ def test_convert_full_integration_with_specific_headers(mock_post, settings): mock_response.raise_for_status.return_value = None mock_post.return_value = mock_response - result = converter.convert( - b"test_content", "application/vnd.yjs.doc", "text/markdown" - ) + result = converter.convert(b"test_content", mime_types.YJS, mime_types.MARKDOWN) assert result == expected_response mock_post.assert_called_once_with( @@ -118,8 +117,8 @@ def test_convert_full_integration_with_specific_headers(mock_post, settings): data=b"test_content", headers={ "Authorization": "Bearer test-key", - "Content-Type": "application/vnd.yjs.doc", - "Accept": "text/markdown", + "Content-Type": mime_types.YJS, + "Accept": mime_types.MARKDOWN, }, timeout=5, verify=False, @@ -135,7 +134,7 @@ def test_convert_timeout(mock_post): with pytest.raises( ServiceUnavailableError, - match="Failed to connect to conversion service", + match="Failed to connect to YDoc conversion service", ): converter.convert("test text") @@ -144,5 +143,5 @@ def test_convert_none_input(): """Should raise ValidationError when input is None.""" converter = YdocConverter() - with pytest.raises(ValidationError, match="Input text cannot be empty"): + with pytest.raises(ValidationError, match="Input data cannot be empty"): converter.convert(None) diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 9ae062cd..42a34499 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -11,7 +11,6 @@ from core.api.viewsets import ReconciliationConfirmView # - Main endpoints router = DefaultRouter() -router.register("templates", viewsets.TemplateViewSet, basename="templates") router.register("documents", viewsets.DocumentViewSet, basename="documents") router.register("users", viewsets.UserViewSet, basename="users") diff --git a/src/backend/demo/data/template/code.txt b/src/backend/demo/data/template/code.txt deleted file mode 100644 index 229af08d..00000000 --- a/src/backend/demo/data/template/code.txt +++ /dev/null @@ -1,2 +0,0 @@ - -
\ No newline at end of file diff --git a/src/backend/demo/data/template/css.txt b/src/backend/demo/data/template/css.txt deleted file mode 100644 index e69de29b..00000000 diff --git a/src/backend/demo/management/commands/create_demo.py b/src/backend/demo/management/commands/create_demo.py index c3a22752..695e2a4a 100644 --- a/src/backend/demo/management/commands/create_demo.py +++ b/src/backend/demo/management/commands/create_demo.py @@ -216,29 +216,6 @@ def create_demo(stdout): queue.flush() - with Timeit(stdout, "Creating Template"): - with open( - file="demo/data/template/code.txt", mode="r", encoding="utf-8" - ) as text_file: - code_data = text_file.read() - - with open( - file="demo/data/template/css.txt", mode="r", encoding="utf-8" - ) as text_file: - css_data = text_file.read() - - queue.push( - models.Template( - id="baca9e2a-59fb-42ef-b5c6-6f6b05637111", - title="Demo Template", - description="This is the demo template", - code=code_data, - css=css_data, - is_public=True, - ) - ) - queue.flush() - class Command(BaseCommand): """A management command to create a demo database.""" diff --git a/src/backend/demo/tests/test_commands_create_demo.py b/src/backend/demo/tests/test_commands_create_demo.py index 6bd19e11..52233301 100644 --- a/src/backend/demo/tests/test_commands_create_demo.py +++ b/src/backend/demo/tests/test_commands_create_demo.py @@ -25,7 +25,6 @@ def test_commands_create_demo(): """The create_demo management command should create objects as expected.""" call_command("create_demo") - assert models.Template.objects.count() == 1 assert models.User.objects.count() >= 10 assert models.Document.objects.count() >= 10 assert models.DocumentAccess.objects.count() > 10 diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index c6e29efb..75101603 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -29,6 +29,10 @@ from sentry_sdk.integrations.logging import ignore_logger BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DATA_DIR = os.getenv("DATA_DIR", os.path.join("/", "data")) +KB = 1024 +MB = KB * KB +GB = MB * KB + def get_release(): """ @@ -168,7 +172,7 @@ class Base(Configuration): # Document images DOCUMENT_IMAGE_MAX_SIZE = values.IntegerValue( - 10 * (2**20), # 10MB + 10 * MB, # 10MB environ_name="DOCUMENT_IMAGE_MAX_SIZE", environ_prefix=None, ) @@ -406,16 +410,6 @@ class Base(Configuration): environ_name="API_DOCUMENT_ACCESS_THROTTLE_RATE", environ_prefix=None, ), - "template": values.Value( - default="30/minute", - environ_name="API_TEMPLATE_THROTTLE_RATE", - environ_prefix=None, - ), - "template_access": values.Value( - default="30/minute", - environ_name="API_TEMPLATE_ACCESS_THROTTLE_RATE", - environ_prefix=None, - ), "invitation": values.Value( default="60/minute", environ_name="API_INVITATION_THROTTLE_RATE", @@ -465,6 +459,7 @@ class Base(Configuration): EMAIL_HOST_PASSWORD = SecretFileValue(None) EMAIL_LOGO_IMG = values.Value(None) EMAIL_PORT = values.PositiveIntegerValue(None) + EMAIL_URL_APP = values.Value(None) EMAIL_USE_TLS = values.BooleanValue(False) EMAIL_USE_SSL = values.BooleanValue(False) EMAIL_FROM = values.Value("from@example.com") @@ -719,6 +714,22 @@ class Base(Configuration): environ_prefix=None, ) + # DocSpec API microservice + DOCSPEC_API_URL = values.Value(environ_name="DOCSPEC_API_URL", environ_prefix=None) + + # Imported file settings + CONVERSION_FILE_MAX_SIZE = values.IntegerValue( + 20 * MB, # 10MB + environ_name="CONVERSION_FILE_MAX_SIZE", + environ_prefix=None, + ) + + CONVERSION_FILE_EXTENSIONS_ALLOWED = values.ListValue( + default=[".docx", ".md"], + environ_name="CONVERSION_FILE_EXTENSIONS_ALLOWED", + environ_prefix=None, + ) + # Conversion endpoint CONVERSION_API_ENDPOINT = values.Value( default="convert", @@ -1064,6 +1075,9 @@ class Production(Base): # Privacy SECURE_REFERRER_POLICY = "same-origin" + # Conversion API: Always verify SSL in production + CONVERSION_API_SECURE = True + CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index d532906d..edc9640f 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -19,7 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", ] -description = "An application to print markdown to pdf from a set of managed templates." +description = "Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing." keywords = ["Django", "Contacts", "Templates", "RBAC"] license = { file = "LICENSE" } readme = "README.md" diff --git a/src/frontend/apps/e2e/.gitignore b/src/frontend/apps/e2e/.gitignore index d248afc8..c4488d95 100644 --- a/src/frontend/apps/e2e/.gitignore +++ b/src/frontend/apps/e2e/.gitignore @@ -1,6 +1,6 @@ # e2e test-results/ -report/ +report*/ blob-report/ playwright/.auth/ playwright/.cache/ diff --git a/src/frontend/apps/e2e/__tests__/app-impress/assets/base-content-test-pdf.txt b/src/frontend/apps/e2e/__tests__/app-impress/assets/base-content-test-pdf.txt index b5d66f4d..29d08152 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/assets/base-content-test-pdf.txt +++ b/src/frontend/apps/e2e/__tests__/app-impress/assets/base-content-test-pdf.txt @@ -1 +1 @@ -"AswD0tKs3gwABwEOZG9jdW1lbnQtc3RvcmUDCmJsb2NrR3JvdXAHANLSrN4MAAMOYmxvY2tDb250YWluZXIHANLSrN4MAQMJcGFyYWdyYXBoBwDS0qzeDAIGBADS0qzeDAMKSGVsbG8gVGV4dCgA0tKs3gwCD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoANLSrN4MAgl0ZXh0Q29sb3IBdwdkZWZhdWx0KADS0qzeDAINdGV4dEFsaWdubWVudAF3BGxlZnQoANLSrN4MAQJpZAF3DmluaXRpYWxCbG9ja0lkh9LSrN4MAQMOYmxvY2tDb250YWluZXIHANLSrN4MEgMHaGVhZGluZwcA0tKs3gwTBgYA0tKs3gwUBGJvbGQCe32G0tKs3gwVCXRleHRDb2xvchZ7InN0cmluZ1ZhbHVlIjoiYmx1ZSJ9hNLSrN4MFg9IZWxsbyBIZWFkaW5nIDGG0tKs3gwlBGJvbGQEbnVsbIbS0qzeDCYJdGV4dENvbG9yBG51bGwoANLSrN4MEw9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KADS0qzeDBMJdGV4dENvbG9yAXcHZGVmYXVsdCgA0tKs3gwTDXRleHRBbGlnbm1lbnQBdwRsZWZ0KADS0qzeDBMFbGV2ZWwBfQEoANLSrN4MEwxpc1RvZ2dsZWFibGUBeSgA0tKs3gwSAmlkAXckNjA0ZmQyNzYtYjIzMy00OGQ0LThlN2ItMTExMmYzYWRhNWEwh9LSrN4MEgMOYmxvY2tDb250YWluZXIHANLSrN4MLgMHaGVhZGluZwcA0tKs3gwvBgYA0tKs3gwwBGJvbGQCe32E0tKs3gwxD0hlbGxvIEhlYWRpbmcgMobS0qzeDEAEYm9sZARudWxsKADS0qzeDC8PYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA0tKs3gwvCXRleHRDb2xvcgF3B2RlZmF1bHQoANLSrN4MLw10ZXh0QWxpZ25tZW50AXcEbGVmdCgA0tKs3gwvBWxldmVsAX0CKADS0qzeDC8MaXNUb2dnbGVhYmxlAXkoANLSrN4MLgJpZAF3JDA1NjU3M2E4LTU4ZGYtNGUyOS05MDMxLTMzOGE2Y2JiNmY4N4fS0qzeDC4DDmJsb2NrQ29udGFpbmVyBwDS0qzeDEgDB2hlYWRpbmcHANLSrN4MSQYGANLSrN4MSgRib2xkAnt9htLSrN4MSw9iYWNrZ3JvdW5kQ29sb3IXeyJzdHJpbmdWYWx1ZSI6ImdyZWVuIn2E0tKs3gxMD0hlbGxvIEhlYWRpbmcgM4bS0qzeDFsEYm9sZARudWxshtLSrN4MXA9iYWNrZ3JvdW5kQ29sb3IEbnVsbCgA0tKs3gxJD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoANLSrN4MSQl0ZXh0Q29sb3IBdwdkZWZhdWx0KADS0qzeDEkNdGV4dEFsaWdubWVudAF3BGxlZnQoANLSrN4MSQVsZXZlbAF9AygA0tKs3gxJDGlzVG9nZ2xlYWJsZQF5KADS0qzeDEgCaWQBdyRkZTQ4ZDc0NC00MGE2LTQ4M2YtODcwNi1kYmM4YzU1NjQ0YWKH0tKs3gxIAw5ibG9ja0NvbnRhaW5lcgcA0tKs3gxkAwlwYXJhZ3JhcGgoANLSrN4MZQ9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KADS0qzeDGUJdGV4dENvbG9yAXcHZGVmYXVsdCgA0tKs3gxlDXRleHRBbGlnbm1lbnQBdwRsZWZ0KADS0qzeDGQCaWQBdyQ2MTdkZjQwYi1kMjdhLTQ3MWYtYmYzMC0zMWJlYjJiY2ExZTmH0tKs3gxkAw5ibG9ja0NvbnRhaW5lcgcA0tKs3gxqAw1jaGVja0xpc3RJdGVtBwDS0qzeDGsGBADS0qzeDGwQQ2hlY2tsaXN0IEl0ZW0gMSgA0tKs3gxrD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoANLSrN4Mawl0ZXh0Q29sb3IBdwdkZWZhdWx0KADS0qzeDGsNdGV4dEFsaWdubWVudAF3BGxlZnQoANLSrN4MawdjaGVja2VkAXkoANLSrN4MagJpZAF3JGExYWVmNjEwLTUyMTMtNDdjMi1hMTc5LWJiYmZmNGMzNjk5ZYfS0qzeDGoDDmJsb2NrQ29udGFpbmVyBwDS0qzeDIIBAw1jaGVja0xpc3RJdGVtBwDS0qzeDIMBBgQA0tKs3gyEARBDaGVja2xpc3QgSXRlbSAyKADS0qzeDIMBD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoANLSrN4MgwEJdGV4dENvbG9yAXcHZGVmYXVsdCgA0tKs3gyDAQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA0tKs3gyDAQdjaGVja2VkAXgoANLSrN4MggECaWQBdyQzOWMyODYwMS1jNGVlLTRmMjUtOThlZS1lNDRkNjJkZGE4MjCH0tKs3gyCAQMOYmxvY2tDb250YWluZXIHANLSrN4MmgEDBWltYWdlKADS0qzeDJsBDXRleHRBbGlnbm1lbnQBdwRsZWZ0KADS0qzeDJsBD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoANLSrN4MmwEEbmFtZQF3CHRlc3Quc3ZnKADS0qzeDJsBA3VybAF3dWh0dHA6Ly9sb2NhbGhvc3Q6ODA4My9tZWRpYS8xZmI0N2QzNS03ZDJhLTQyMmItYjZmNS1lOWY3NGU0ZGVjNDgvYXR0YWNobWVudHMvYzg3ZWZhMDctZDZmMi00ODgxLWIxNDEtNjExNjBiYWRiMTg3LnN2ZygA0tKs3gybAQdjYXB0aW9uAXcAKADS0qzeDJsBC3Nob3dQcmV2aWV3AXgoANLSrN4MmwEMcHJldmlld1dpZHRoAX8oANLSrN4MmgECaWQBdyRlNGY3MzJmOC0xN2FjLTRhYWMtYmJmMi0xOTVlYzU1ZjIzMmKH0tKs3gyaAQMOYmxvY2tDb250YWluZXIHANLSrN4MpAEDBXF1b3RlBwDS0qzeDKUBBgQA0tKs3gymAQtIZWxsbyBRdW90ZSgA0tKs3gylAQ9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KADS0qzeDKUBCXRleHRDb2xvcgF3B2RlZmF1bHQoANLSrN4MpAECaWQBdyQxODU0ZDc5Yi01NTkwLTQwY2QtODQwNS1lODEyNjBhZWVlYjKH0tKs3gykAQMOYmxvY2tDb250YWluZXIHANLSrN4MtQEDCXBhcmFncmFwaCgA0tKs3gy2AQ9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KADS0qzeDLYBCXRleHRDb2xvcgF3B2RlZmF1bHQoANLSrN4MtgENdGV4dEFsaWdubWVudAF3BGxlZnQoANLSrN4MtQECaWQBdyQ5YTFmYzZiYS0wYWE2LTQ3ZmItOTc4MS00ZjMwOWY5OWNkZDGH0tKs3gy1AQMOYmxvY2tDb250YWluZXIHANLSrN4MuwEDDnRvZ2dsZUxpc3RJdGVtBwDS0qzeDLwBBgQA0tKs3gy9ARNIZWxsbyBUb2dnbGUgTGlzdCAxKADS0qzeDLwBD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoANLSrN4MvAEJdGV4dENvbG9yAXcHZGVmYXVsdCgA0tKs3gy8AQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA0tKs3gy7AQJpZAF3JGFjNzU3MmM2LTU3N2MtNDFlZi1iZTM5LTVmYjhmYzI2ZTZkMYfS0qzeDLsBAw5ibG9ja0NvbnRhaW5lcgcA0tKs3gzVAQMJcGFyYWdyYXBoKADS0qzeDNYBD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoANLSrN4M1gEJdGV4dENvbG9yAXcHZGVmYXVsdCgA0tKs3gzWAQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA0tKs3gzVAQJpZAF3JDNhZWFkZWZlLTZkYjUtNDdhNi04MmY1LTE4MDU4YjBhNDg0MIfS0qzeDNUBAw5ibG9ja0NvbnRhaW5lcgcA0tKs3gzbAQMQbnVtYmVyZWRMaXN0SXRlbQcA0tKs3gzcAQYEANLSrN4M3QEUTnVtYmVyZWQgTGlzdCBJdGVtIDEoANLSrN4M3AEPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA0tKs3gzcAQl0ZXh0Q29sb3IBdwdkZWZhdWx0KADS0qzeDNwBDXRleHRBbGlnbm1lbnQBdwRsZWZ0KADS0qzeDNwBBXN0YXJ0AX8oANLSrN4M2wECaWQBdyRmMWE0Yzg0NS1iYTAxLTRjYjEtYjI1Mi1lOWQxYjlhZTZkNWaH0tKs3gzbAQMOYmxvY2tDb250YWluZXIHANLSrN4M9wEDEG51bWJlcmVkTGlzdEl0ZW0HANLSrN4M+AEGBADS0qzeDPkBFE51bWJlcmVkIExpc3QgSXRlbSAyKADS0qzeDPgBD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoANLSrN4M+AEJdGV4dENvbG9yAXcHZGVmYXVsdCgA0tKs3gz4AQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA0tKs3gz4AQVzdGFydAF/KADS0qzeDPcBAmlkAXckYzE2NzMxNTctODgzNS00Yjg4LWI0MzgtZDI4YmE1ZmFhNDE1h9LSrN4M9wEDDmJsb2NrQ29udGFpbmVyBwDS0qzeDJMCAwlwYXJhZ3JhcGgoANLSrN4MlAIPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA0tKs3gyUAgl0ZXh0Q29sb3IBdwdkZWZhdWx0KADS0qzeDJQCDXRleHRBbGlnbm1lbnQBdwRsZWZ0KADS0qzeDJMCAmlkAXckZTkwN2U4NzYtMTU0MC00N2U0LTkyNWItMDI0MDE4MmJkMzRlh9LSrN4MkwIDDmJsb2NrQ29udGFpbmVyBwDS0qzeDJkCAw5idWxsZXRMaXN0SXRlbQcA0tKs3gyaAgYEANLSrN4MmwISQnVsbGV0IExpc3QgSXRlbSAxKADS0qzeDJoCD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoANLSrN4MmgIJdGV4dENvbG9yAXcHZGVmYXVsdCgA0tKs3gyaAg10ZXh0QWxpZ25tZW50AXcEbGVmdCgA0tKs3gyZAgJpZAF3JDY0N2JmMzhjLWRlMDgtNGE0YS04MTg1LWVkOTRlZDVjYWZjNYfS0qzeDJkCAw5ibG9ja0NvbnRhaW5lcgcA0tKs3gyyAgMOYnVsbGV0TGlzdEl0ZW0HANLSrN4MswIGBADS0qzeDLQCEkJ1bGxldCBMaXN0IEl0ZW0gMigA0tKs3gyzAg9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KADS0qzeDLMCCXRleHRDb2xvcgF3B2RlZmF1bHQoANLSrN4MswINdGV4dEFsaWdubWVudAF3BGxlZnQoANLSrN4MsgICaWQBdyQzMjRiMTA0Yi03MzgwLTRlM2YtOWU3Zi1jNTg0NzVkMDA4OTOH0tKs3gyyAgMOYmxvY2tDb250YWluZXIHANLSrN4MywIDCXBhcmFncmFwaCgA0tKs3gzMAg9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KADS0qzeDMwCCXRleHRDb2xvcgF3B2RlZmF1bHQoANLSrN4MzAINdGV4dEFsaWdubWVudAF3BGxlZnQoANLSrN4MywICaWQBdyQzMjc4NjIyMC02Yjc3LTRlZjktYmM3Ny03ODM3OTI1YjZiY2KH0tKs3gzLAgMOYmxvY2tDb250YWluZXIHANLSrN4M0QIDCWNvZGVCbG9jawcA0tKs3gzSAgYGANLSrN4M0wIJdGV4dENvbG9yJHsic3RyaW5nVmFsdWUiOiJyZ2IoMjQ5LCAxMTcsIDEzMSkifYTS0qzeDNQCBWNvbnN0htLSrN4M2QIJdGV4dENvbG9yBG51bGyG0tKs3gzaAgl0ZXh0Q29sb3IkeyJzdHJpbmdWYWx1ZSI6InJnYigyMjUsIDIyOCwgMjMyKSJ9hNLSrN4M2wIBIIbS0qzeDNwCCXRleHRDb2xvcgRudWxshtLSrN4M3QIJdGV4dENvbG9yJHsic3RyaW5nVmFsdWUiOiJyZ2IoMTIxLCAxODQsIDI1NSkifYTS0qzeDN4CAWGG0tKs3gzfAgl0ZXh0Q29sb3IEbnVsbIbS0qzeDOACCXRleHRDb2xvciR7InN0cmluZ1ZhbHVlIjoicmdiKDIyNSwgMjI4LCAyMzIpIn2E0tKs3gzhAgEghtLSrN4M4gIJdGV4dENvbG9yBG51bGyG0tKs3gzjAgl0ZXh0Q29sb3IkeyJzdHJpbmdWYWx1ZSI6InJnYigyNDksIDExNywgMTMxKSJ9hNLSrN4M5AIBPYbS0qzeDOUCCXRleHRDb2xvcgRudWxshtLSrN4M5gIJdGV4dENvbG9yJHsic3RyaW5nVmFsdWUiOiJyZ2IoMjI1LCAyMjgsIDIzMikifYTS0qzeDOcCASCG0tKs3gzoAgl0ZXh0Q29sb3IEbnVsbIbS0qzeDOkCCXRleHRDb2xvciR7InN0cmluZ1ZhbHVlIjoicmdiKDEyMSwgMTg0LCAyNTUpIn2E0tKs3gzqAgIxMIbS0qzeDOwCCXRleHRDb2xvcgRudWxshtLSrN4M7QIJdGV4dENvbG9yJHsic3RyaW5nVmFsdWUiOiJyZ2IoMjI1LCAyMjgsIDIzMikifYTS0qzeDO4CATuG0tKs3gzvAgl0ZXh0Q29sb3IEbnVsbCgA0tKs3gzSAghsYW5ndWFnZQF3CmphdmFzY3JpcHQoANLSrN4M0QICaWQBdyQ0MWE1M2E4MS1lZjc3LTQwODgtODY2NC0yZTgyYjQ0OWMxZDKH0tKs3gzRAgMOYmxvY2tDb250YWluZXIHANLSrN4M8wIDCXBhcmFncmFwaCgA0tKs3gz0Ag9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KADS0qzeDPQCCXRleHRDb2xvcgF3B2RlZmF1bHQoANLSrN4M9AINdGV4dEFsaWdubWVudAF3BGxlZnQoANLSrN4M8wICaWQBdyQ5MzY0OTQ0MS01MWM4LTQyN2YtYjBmYS0zNGQyZDQ5ZTQ4MDKH0tKs3gzzAgMOYmxvY2tDb250YWluZXIHANLSrN4M+QIDB2RpdmlkZXIoANLSrN4M+QICaWQBdyQ2ZDJiMmY1OS1hODFmLTQ1OGMtOGFmMy1kZjkyZmI4YmFkYmGH0tKs3gz5AgMOYmxvY2tDb250YWluZXIHANLSrN4M/AIDCXBhcmFncmFwaCgA0tKs3gz9Ag9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KADS0qzeDP0CCXRleHRDb2xvcgF3B2RlZmF1bHQoANLSrN4M/QINdGV4dEFsaWdubWVudAF3BGxlZnQoANLSrN4M/AICaWQBdyRkNTFiNDAwMS03YTRlLTQ3NzYtYWUyOC0wODc5ODk0NDgyMzaH0tKs3gz8AgMOYmxvY2tDb250YWluZXIHANLSrN4MggMDCXBhZ2VCcmVhaygA0tKs3gyCAwJpZAF3JDI5MmJjNjNiLTJlN2UtNGNlMy1hOTg3LTA0MjIyMGZhNjIwNYfS0qzeDIIDAwpjb2x1bW5MaXN0BwDS0qzeDIUDAwZjb2x1bW4HANLSrN4MhgMDDmJsb2NrQ29udGFpbmVyBwDS0qzeDIcDAwlwYXJhZ3JhcGgHANLSrN4MiAMGBADS0qzeDIkDDUNvbHVtbiAxIFRleHQoANLSrN4MiAMPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA0tKs3gyIAwl0ZXh0Q29sb3IBdwdkZWZhdWx0KADS0qzeDIgDDXRleHRBbGlnbm1lbnQBdwRsZWZ0KADS0qzeDIcDAmlkAXckZmViOTI4ZTEtNjcxOC00OGJkLWIyMTctMjMyNTg4MmU4NmRjKADS0qzeDIYDAmlkAXckODM0ZTczMTUtYTRmMi00NGEzLWEwZmItNjU5MjdlZjU4YzRiKADS0qzeDIYDBXdpZHRoAX0Bh9LSrN4MhgMDBmNvbHVtbgcA0tKs3gydAwMOYmxvY2tDb250YWluZXIHANLSrN4MngMDCXBhcmFncmFwaAcA0tKs3gyfAwYEANLSrN4MoAMNQ29sdW1uIDIgVGV4dCgA0tKs3gyfAw9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KADS0qzeDJ8DCXRleHRDb2xvcgF3B2RlZmF1bHQoANLSrN4MnwMNdGV4dEFsaWdubWVudAF3BGxlZnQoANLSrN4MngMCaWQBdyRmMzkwNDE2Mi1hZTZhLTQ2MGMtODZjNC02ODVlN2U5N2VkYmIoANLSrN4MnQMCaWQBdyQwNGVlOTI0ZS03N2QwLTRkYTMtOTkwOC0xZjc4ZWIyZjU3ZTEoANLSrN4MnQMFd2lkdGgBfQGH0tKs3gydAwMGY29sdW1uBwDS0qzeDLQDAw5ibG9ja0NvbnRhaW5lcgcA0tKs3gy1AwMJcGFyYWdyYXBoBwDS0qzeDLYDBgQA0tKs3gy3Aw1Db2x1bW4gMyBUZXh0KADS0qzeDLYDD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoANLSrN4MtgMJdGV4dENvbG9yAXcHZGVmYXVsdCgA0tKs3gy2Aw10ZXh0QWxpZ25tZW50AXcEbGVmdCgA0tKs3gy1AwJpZAF3JDhmZjQ4ZmQ3LWEzYTctNDEyNy1iNDY0LTFiZGE0ODI4YmQwNIfS0qzeDLUDAw5ibG9ja0NvbnRhaW5lcgcA0tKs3gzJAwMJcGFyYWdyYXBoKADS0qzeDMoDD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoANLSrN4MygMJdGV4dENvbG9yAXcHZGVmYXVsdCgA0tKs3gzKAw10ZXh0QWxpZ25tZW50AXcEbGVmdCgA0tKs3gzJAwJpZAF3JGE4ZjhkYWVmLWZhZjYtNDI4OS1iNzI1LTJiZTQwMjE1YjBlYygA0tKs3gy0AwJpZAF3JDU3NWUyN2EwLWY2YjItNDc3My1hNjEzLWQ2MTc4OTFhOGQ1NygA0tKs3gy0AwV3aWR0aAF9ASgA0tKs3gyFAwJpZAF3JDcxNTVlMDI2LTcwMGUtNDhlMS1hMjYxLTczNTZkZmY2NDRlMIfS0qzeDIUDAw5ibG9ja0NvbnRhaW5lcgcA0tKs3gzSAwMHY2FsbG91dCgA0tKs3gzTAw10ZXh0QWxpZ25tZW50AXcEbGVmdCgA0tKs3gzTAw9iYWNrZ3JvdW5kQ29sb3IBdwZ5ZWxsb3coANLSrN4M0wMFZW1vamkBdwTwn5KhKADS0qzeDNIDAmlkAXckOTNlZDc4ODEtYmRiZS00NWU4LWI0ZjAtODczZjIxMTMzZjVhh9LSrN4M0gMDDmJsb2NrQ29udGFpbmVyBwDS0qzeDNgDAwlwYXJhZ3JhcGgoANLSrN4M2QMPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA0tKs3gzZAwl0ZXh0Q29sb3IBdwdkZWZhdWx0KADS0qzeDNkDDXRleHRBbGlnbm1lbnQBdwRsZWZ0KADS0qzeDNgDAmlkAXckNDA3MTJmZDgtZWIzYS00M2EwLWI2MTctOGE3MzY2NjM2MWU4h9LSrN4M2AMDDmJsb2NrQ29udGFpbmVyBwDS0qzeDN4DAwV0YWJsZQcA0tKs3gzfAwMIdGFibGVSb3cHANLSrN4M4AMDCXRhYmxlQ2VsbAcA0tKs3gzhAwMOdGFibGVQYXJhZ3JhcGgHANLSrN4M4gMGBADS0qzeDOMDBkNlbGwgMSgA0tKs3gzhAwl0ZXh0Q29sb3IBdwdkZWZhdWx0KADS0qzeDOEDD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoANLSrN4M4QMNdGV4dEFsaWdubWVudAF3BGxlZnQoANLSrN4M4QMHY29sc3BhbgF9ASgA0tKs3gzhAwdyb3dzcGFuAX0Bh9LSrN4M4QMDCXRhYmxlQ2VsbAcA0tKs3gzvAwMOdGFibGVQYXJhZ3JhcGgHANLSrN4M8AMGBADS0qzeDPEDBkNlbGwgMigA0tKs3gzvAwl0ZXh0Q29sb3IBdwdkZWZhdWx0KADS0qzeDO8DD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoANLSrN4M7wMNdGV4dEFsaWdubWVudAF3BGxlZnQoANLSrN4M7wMHY29sc3BhbgF9ASgA0tKs3gzvAwdyb3dzcGFuAX0Bh9LSrN4M7wMDCXRhYmxlQ2VsbAcA0tKs3gz9AwMOdGFibGVQYXJhZ3JhcGgHANLSrN4M/gMGBADS0qzeDP8DBkNlbGwgMygA0tKs3gz9Awl0ZXh0Q29sb3IBdwdkZWZhdWx0KADS0qzeDP0DD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoANLSrN4M/QMNdGV4dEFsaWdubWVudAF3BGxlZnQoANLSrN4M/QMHY29sc3BhbgF9ASgA0tKs3gz9Awdyb3dzcGFuAX0Bh9LSrN4M4AMDCHRhYmxlUm93BwDS0qzeDIsEAwl0YWJsZUNlbGwHANLSrN4MjAQDDnRhYmxlUGFyYWdyYXBoBwDS0qzeDI0EBgQA0tKs3gyOBAZDZWxsIDQoANLSrN4MjAQJdGV4dENvbG9yAXcHZGVmYXVsdCgA0tKs3gyMBA9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KADS0qzeDIwEDXRleHRBbGlnbm1lbnQBdwRsZWZ0KADS0qzeDIwEB2NvbHNwYW4BfQEoANLSrN4MjAQHcm93c3BhbgF9AYfS0qzeDIwEAwl0YWJsZUNlbGwHANLSrN4MmgQDDnRhYmxlUGFyYWdyYXBoBwDS0qzeDJsEBgQA0tKs3gycBAZDZWxsIDUoANLSrN4MmgQJdGV4dENvbG9yAXcHZGVmYXVsdCgA0tKs3gyaBA9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KADS0qzeDJoEDXRleHRBbGlnbm1lbnQBdwRsZWZ0KADS0qzeDJoEB2NvbHNwYW4BfQEoANLSrN4MmgQHcm93c3BhbgF9AYfS0qzeDJoEAwl0YWJsZUNlbGwHANLSrN4MqAQDDnRhYmxlUGFyYWdyYXBoBwDS0qzeDKkEBgQA0tKs3gyqBAZDZWxsIDYoANLSrN4MqAQJdGV4dENvbG9yAXcHZGVmYXVsdCgA0tKs3gyoBA9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KADS0qzeDKgEDXRleHRBbGlnbm1lbnQBdwRsZWZ0KADS0qzeDKgEB2NvbHNwYW4BfQEoANLSrN4MqAQHcm93c3BhbgF9ASgA0tKs3gzfAwl0ZXh0Q29sb3IBdwdkZWZhdWx0KADS0qzeDN4DAmlkAXckYjAzMzBjZjctOTAwNS00ODg1LWJjMDMtODM5OWZiY2UwZTFih9LSrN4M3gMDDmJsb2NrQ29udGFpbmVyBwDS0qzeDLgEAwlwYXJhZ3JhcGgoANLSrN4MuQQPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA0tKs3gy5BAl0ZXh0Q29sb3IBdwdkZWZhdWx0KADS0qzeDLkEDXRleHRBbGlnbm1lbnQBdwRsZWZ0KADS0qzeDLgEAmlkAXckYTgwZTMzNWMtNmNkOC00MTJlLTkxMmUtZjdiM2EyOGUyNjEwh9LSrN4MuAQDDmJsb2NrQ29udGFpbmVyBwDS0qzeDL4EAwlwYXJhZ3JhcGgHANLSrN4MvwQDGGludGVybGlua2luZ1NlYXJjaElubGluZSgA0tKs3gzABAd0cmlnZ2VyAXcBLygA0tKs3gzABAhkaXNhYmxlZAF4h9LSrN4MwAQDFmludGVybGlua2luZ0xpbmtJbmxpbmUoANLSrN4MwwQDdXJsAXcqL2RvY3MvMDIyZDA0OGMtNmQwNy00ZTU4LWIzZmMtOWM0M2EyODcxMmU5KADS0qzeDMMEBWRvY0lkAXckMDIyZDA0OGMtNmQwNy00ZTU4LWIzZmMtOWM0M2EyODcxMmU5KADS0qzeDMMEBXRpdGxlAXcPVGVzdCBSZWdyZXNzaW9uKADS0qzeDL8ED2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoANLSrN4MvwQJdGV4dENvbG9yAXcHZGVmYXVsdCgA0tKs3gy/BA10ZXh0QWxpZ25tZW50AXcEbGVmdIHS0qzeDL8EAQAQKADS0qzeDL4EAmlkAXckNDBiMmI5OTEtOTQ1ZC00ZGVmLTkxNWMtZGU0Y2JkYTAyOTdih9LSrN4MvgQDDmJsb2NrQ29udGFpbmVyBwDS0qzeDNwEAwdoZWFkaW5nBwDS0qzeDN0EBgYA0tKs3gzeBARib2xkAnt9hNLSrN4M3wQSSGVsbG8gVGl0bGUgVG9nZ2xlhtLSrN4M8QQEYm9sZARudWxsKADS0qzeDN0ED2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoANLSrN4M3QQJdGV4dENvbG9yAXcHZGVmYXVsdCgA0tKs3gzdBA10ZXh0QWxpZ25tZW50AXcEbGVmdCgA0tKs3gzdBAVsZXZlbAF9ASgA0tKs3gzdBAxpc1RvZ2dsZWFibGUBeCgA0tKs3gzcBAJpZAF3JGNiY2JlM2U1LTg0ODYtNDQ5MS05MjEwLWU3MGJmM2I4MjBhM4fS0qzeDNwEAw5ibG9ja0NvbnRhaW5lcgcA0tKs3gz5BAMJcGFyYWdyYXBoKADS0qzeDPoED2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoANLSrN4M+gQJdGV4dENvbG9yAXcHZGVmYXVsdCgA0tKs3gz6BA10ZXh0QWxpZ25tZW50AXcEbGVmdCgA0tKs3gz5BAJpZAF3JGY4ZTQ1Nzc3LWVmOWMtNDcyZS05Y2Y4LTJkMTlhZDE0N2Y3MYfS0qzeDPkEAw5ibG9ja0NvbnRhaW5lcgcA0tKs3gz/BAMHaGVhZGluZwcA0tKs3gyABQYGANLSrN4MgQUEYm9sZAJ7fYTS0qzeDIIFDUhlbGxvIHRpdGxlIDSG0tKs3gyPBQRib2xkBG51bGwoANLSrN4MgAUPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA0tKs3gyABQl0ZXh0Q29sb3IBdwdkZWZhdWx0KADS0qzeDIAFDXRleHRBbGlnbm1lbnQBdwRsZWZ0KADS0qzeDIAFBWxldmVsAX0EKADS0qzeDIAFDGlzVG9nZ2xlYWJsZQF5KADS0qzeDP8EAmlkAXckNzAwYzZhNDItZTRjZC00Yzg5LWJhYzctZTMzOTQ1Yzc0MWIwh9LSrN4M/wQDDmJsb2NrQ29udGFpbmVyBwDS0qzeDJcFAwlwYXJhZ3JhcGgoANLSrN4MmAUPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA0tKs3gyYBQl0ZXh0Q29sb3IBdwdkZWZhdWx0KADS0qzeDJgFDXRleHRBbGlnbm1lbnQBdwRsZWZ0KADS0qzeDJcFAmlkAXckOGYxNDkyNGUtOTFkMS00Y2U2LTg3NDQtMDE0ZjUyN2NjMzRjh9LSrN4MlwUDDmJsb2NrQ29udGFpbmVyBwDS0qzeDJ0FAwdoZWFkaW5nBwDS0qzeDJ4FBgYA0tKs3gyfBQRib2xkAnt9hNLSrN4MoAUNSGVsbG8gdGl0bGUgNYbS0qzeDK0FBGJvbGQEbnVsbCgA0tKs3gyeBQ9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KADS0qzeDJ4FCXRleHRDb2xvcgF3B2RlZmF1bHQoANLSrN4MngUNdGV4dEFsaWdubWVudAF3BGxlZnQoANLSrN4MngUFbGV2ZWwBfQUoANLSrN4MngUMaXNUb2dnbGVhYmxlAXkoANLSrN4MnQUCaWQBdyRmOGE1NTkzOS1hN2MwLTQ4YmUtYjFkMi1mYjA4MmIzMmJiYjSH0tKs3gydBQMOYmxvY2tDb250YWluZXIHANLSrN4MtQUDCXBhcmFncmFwaCgA0tKs3gy2BQ9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KADS0qzeDLYFCXRleHRDb2xvcgF3B2RlZmF1bHQoANLSrN4MtgUNdGV4dEFsaWdubWVudAF3BGxlZnQoANLSrN4MtQUCaWQBdyQ1ZTEwOTc4My0xODhkLTQwMjUtOWViZS01OTQ2M2FlMjZiMmaH0tKs3gy1BQMOYmxvY2tDb250YWluZXIHANLSrN4MuwUDB2hlYWRpbmcHANLSrN4MvAUGBgDS0qzeDL0FBGJvbGQCe32E0tKs3gy+BQ1IZWxsbyB0aXRsZSA2htLSrN4MywUEYm9sZARudWxsKADS0qzeDLwFD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoANLSrN4MvAUJdGV4dENvbG9yAXcHZGVmYXVsdCgA0tKs3gy8BQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA0tKs3gy8BQVsZXZlbAF9BigA0tKs3gy8BQxpc1RvZ2dsZWFibGUBeSgA0tKs3gy7BQJpZAF3JDQwMjAyZDE0LTljY2UtNGMxNi05YWI4LWE4Mzg4YjM5ZGFkNofS0qzeDLsFAw5ibG9ja0NvbnRhaW5lcgcA0tKs3gzTBQMJcGFyYWdyYXBoKADS0qzeDNQFD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoANLSrN4M1AUJdGV4dENvbG9yAXcHZGVmYXVsdCgA0tKs3gzUBQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA0tKs3gzTBQJpZAF3JDcwYmJiMzRmLTFkNWItNDA3OC1iMWU2LWQ1Yzc3MWE0OTFhOIfS0qzeDNMFAw5ibG9ja0NvbnRhaW5lcgcA0tKs3gzZBQMJcGFyYWdyYXBoBwDS0qzeDNoFBgQA0tKs3gzbBQ3wn6eZ4oCN4pmC77iPhNLSrN4M4AUMIE1hZ2ljIGVtb2ppKADS0qzeDNoFD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoANLSrN4M2gUJdGV4dENvbG9yAXcHZGVmYXVsdCgA0tKs3gzaBQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA0tKs3gzZBQJpZAF3JGE2YmY3MjYzLTc4YmMtNGQyNS05YzRkLTE2OWE5YmRjNzJkN4fS0qzeDNkFAw5ibG9ja0NvbnRhaW5lcgcA0tKs3gzxBQMHaGVhZGluZwcA0tKs3gzyBQYGANLSrN4M8wUEYm9sZAJ7fYTS0qzeDPQFF2NvcHkvcGFzdGluZyBvdXQgb2YgZG9jhtLSrN4MiwYEYm9sZARudWxsKADS0qzeDPIFD2JhY2tncm91bmRDb2xvcgF3D3JnYigxMywgMTcsIDIzKSgA0tKs3gzyBQl0ZXh0Q29sb3IBdxJyZ2IoMjQwLCAyNDYsIDI1MikoANLSrN4M8gUNdGV4dEFsaWdubWVudAF3BGxlZnQoANLSrN4M8gUFbGV2ZWwBfQEoANLSrN4M8gUMaXNUb2dnbGVhYmxlAXkoANLSrN4M8QUCaWQBdyQxYTUxZDE2OS1jYjRkLTRjM2QtYTI2Ni01Y2Q0NzVhYzg2YzmH0tKs3gzxBQMOYmxvY2tDb250YWluZXIHANLSrN4MkwYDCXBhcmFncmFwaCgA0tKs3gyUBg9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KADS0qzeDJQGCXRleHRDb2xvcgF3B2RlZmF1bHQoANLSrN4MlAYNdGV4dEFsaWdubWVudAF3BGxlZnQhANLSrN4MkwYCaWQBgdLSrN4MkwYBAAmB0tKs3gyZBgEABsTS0qzeDOAF0tKs3gzhBTzwn5iD8J+OifCfmoDwn5mL4oCN4pmA77iP8J+nkfCfj7/igI3inaTvuI/igI3wn5KL4oCN8J+nkfCfj77G0tKs3gzgBdLSrN4MqgYJdGV4dENvbG9yIXsic3RyaW5nVmFsdWUiOiJyZ2IoNjksIDY5LCA4OCkifcbS0qzeDMQG0tKs3gyqBg9iYWNrZ3JvdW5kQ29sb3IkeyJzdHJpbmdWYWx1ZSI6InJnYigyNTUsIDI1NSwgMjU1KSJ9xtLSrN4MwwbS0qzeDOEFCXRleHRDb2xvcgRudWxsxtLSrN4MxgbS0qzeDOEFD2JhY2tncm91bmRDb2xvcgRudWxswdLSrN4MkwbS0qzeDJkGAQAFwdLSrN4MyAbS0qzeDJkGAQAFqNLSrN4MmAYBdyQ5MTRmZDE1NC0wMjMwLTRjYzUtODk1ZS0wNDIwNmI1NDRmZTjH0tKs3gzxBdLSrN4MkwYDDmJsb2NrQ29udGFpbmVyBwDS0qzeDNUGAwlwYXJhZ3JhcGgoANLSrN4M1gYPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA0tKs3gzWBgl0ZXh0Q29sb3IBdwdkZWZhdWx0KADS0qzeDNYGDXRleHRBbGlnbm1lbnQBdwRsZWZ0KADS0qzeDNUGAmlkAXckYTUxY2U3OWItM2E2Ny00YjVjLWExNTEtZDY2NzVjZmExNWVmwdLSrN4MvgTS0qzeDNwEAQAFgdLSrN4MiwQBABUL5YLkmgcAx9LSrN4MggHS0qzeDJoBAw5ibG9ja0NvbnRhaW5lcgcA5YLkmgcAAw1jaGVja0xpc3RJdGVtKADlguSaBwEPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA5YLkmgcBCXRleHRDb2xvcgF3B2RlZmF1bHQoAOWC5JoHAQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA5YLkmgcBB2NoZWNrZWQBeSgA5YLkmgcAAmlkAXckYjViNWVlMWUtZjk3Zi00ZGE3LWI4NDEtZTNiNDU1ODhmZTRmR+WC5JoHAQMJcGFyYWdyYXBoKADlguSaBwcPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA5YLkmgcHCXRleHRDb2xvcgF3B2RlZmF1bHQoAOWC5JoHBw10ZXh0QWxpZ25tZW50AXcEbGVmdALS0qzeDAWaAQrKBBGYBhLIBgzbBhzlguSaBwEBBQ==" \ No newline at end of file +"AhLJoobnCQCon5aq3wi2CgJ3JDZiZjc3NDE2LWMwN2EtNGM5Yy1iODk4LTEyMjk5YTNmZjlkOHckY2U0ZDQ4ODgtM2RiYS00YTM1LTlkNzktZTI5NmIxYWI3NjIwBwCflqrfCM8DBgQAyaKG5wkCBUhlbGxvhMmihucJBwfCoCBDb8KghMmihucJDAggQ2FsbG91dMeflqrfCKUIn5aq3wjCCAMOYmxvY2tDb250YWluZXIHAMmihucJFQMJcGFyYWdyYXBoKADJoobnCRYPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgAyaKG5wkWCXRleHRDb2xvcgF3B2RlZmF1bHQoAMmihucJFg10ZXh0QWxpZ25tZW50AXcEbGVmdCgAyaKG5wkVAmlkAXckNjUxODk5ZmUtZTI5Yy00ZTRkLTg2OTQtYmY5YTNlZDQwNWZmqJ+Wqt8IwQgBdyQwZTVmMDcwNS0yY2JjLTQ5ZDAtYWE1OS0wNDE4YTFhNDc5MTHHn5aq3wjgA5+Wqt8InwgDDmJsb2NrQ29udGFpbmVyBwDJoobnCRwDCXBhcmFncmFwaCgAyaKG5wkdD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAMmihucJHQl0ZXh0Q29sb3IBdwdkZWZhdWx0KADJoobnCR0NdGV4dEFsaWdubWVudAF3BGxlZnQoAMmihucJHAJpZAF3JGZiN2UzMTRmLTA3YjEtNDFhZi04OTIyLWNmNTM3YjRkYjNjYf8Fn5aq3wgABwEOZG9jdW1lbnQtc3RvcmUDCmJsb2NrR3JvdXAHAJ+Wqt8IAAMOYmxvY2tDb250YWluZXIHAJ+Wqt8IAQMJcGFyYWdyYXBoBwCflqrfCAIGBACflqrfCAMKSGVsbG8gVGV4dCgAn5aq3wgCD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8IAgl0ZXh0Q29sb3IBdwdkZWZhdWx0KACflqrfCAINdGV4dEFsaWdubWVudAF3BGxlZnQoAJ+Wqt8IAQJpZAF3DmluaXRpYWxCbG9ja0lkh5+Wqt8IAQMOYmxvY2tDb250YWluZXIHAJ+Wqt8IEgMHaGVhZGluZwcAn5aq3wgTBgYAn5aq3wgUBGJvbGQCe32Gn5aq3wgVCXRleHRDb2xvchZ7InN0cmluZ1ZhbHVlIjoiYmx1ZSJ9hJ+Wqt8IFg9IZWxsbyBIZWFkaW5nIDGGn5aq3wglBGJvbGQEbnVsbIaflqrfCCYJdGV4dENvbG9yBG51bGwoAJ+Wqt8IEw9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACflqrfCBMJdGV4dENvbG9yAXcHZGVmYXVsdCgAn5aq3wgTDXRleHRBbGlnbm1lbnQBdwRsZWZ0KACflqrfCBMFbGV2ZWwBfQEoAJ+Wqt8IEwxpc1RvZ2dsZWFibGUBeSgAn5aq3wgSAmlkAXckYzRjMjY0OTUtY2E5Yy00Yzc4LTkzZGItMzQxMWM3YjQ3ZDVmh5+Wqt8IEgMOYmxvY2tDb250YWluZXIHAJ+Wqt8ILgMHaGVhZGluZwcAn5aq3wgvBgYAn5aq3wgwBGJvbGQCe32En5aq3wgxD0hlbGxvIEhlYWRpbmcgMoaflqrfCEAEYm9sZARudWxsKACflqrfCC8PYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgAn5aq3wgvCXRleHRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8ILw10ZXh0QWxpZ25tZW50AXcEbGVmdCgAn5aq3wgvBWxldmVsAX0CKACflqrfCC8MaXNUb2dnbGVhYmxlAXkoAJ+Wqt8ILgJpZAF3JDM3OWU0ODQ0LWU1ZjUtNGY3Yi1hNWJjLWVjOTc2N2IxNGFiOIeflqrfCC4DDmJsb2NrQ29udGFpbmVyBwCflqrfCEgDB2hlYWRpbmcHAJ+Wqt8ISQYGAJ+Wqt8ISgRib2xkAnt9hp+Wqt8ISw9iYWNrZ3JvdW5kQ29sb3IXeyJzdHJpbmdWYWx1ZSI6ImdyZWVuIn2En5aq3whMD0hlbGxvIEhlYWRpbmcgM4aflqrfCFsEYm9sZARudWxshp+Wqt8IXA9iYWNrZ3JvdW5kQ29sb3IEbnVsbCgAn5aq3whJD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8ISQl0ZXh0Q29sb3IBdwdkZWZhdWx0KACflqrfCEkNdGV4dEFsaWdubWVudAF3BGxlZnQoAJ+Wqt8ISQVsZXZlbAF9AygAn5aq3whJDGlzVG9nZ2xlYWJsZQF5KACflqrfCEgCaWQBdyRmZTlkZWEwZi1jY2Y3LTQwM2QtYmMwNS1jOTMxMjM5OGZkZDOHn5aq3whIAw5ibG9ja0NvbnRhaW5lcgcAn5aq3whkAwlwYXJhZ3JhcGgoAJ+Wqt8IZQ9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACflqrfCGUJdGV4dENvbG9yAXcHZGVmYXVsdCgAn5aq3whlDXRleHRBbGlnbm1lbnQBdwRsZWZ0KACflqrfCGQCaWQBdyQwMDMzNDlmNy02NTEzLTRlOTQtOWFkMS00MzBhZDgwNTQyNWWHn5aq3whkAw5ibG9ja0NvbnRhaW5lcgcAn5aq3whqAw1jaGVja0xpc3RJdGVtBwCflqrfCGsGBACflqrfCGwQQ2hlY2tsaXN0IEl0ZW0gMSgAn5aq3whrD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8Iawl0ZXh0Q29sb3IBdwdkZWZhdWx0KACflqrfCGsNdGV4dEFsaWdubWVudAF3BGxlZnQoAJ+Wqt8IawdjaGVja2VkAXkoAJ+Wqt8IagJpZAF3JDc5MmJlM2E3LWMzODQtNDEzYy1iMmMzLWE4Y2NjNDFkZmI0NoeflqrfCGoDDmJsb2NrQ29udGFpbmVyBwCflqrfCIIBAw1jaGVja0xpc3RJdGVtBwCflqrfCIMBBgQAn5aq3wiEARBDaGVja2xpc3QgSXRlbSAyKACflqrfCIMBD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8IgwEJdGV4dENvbG9yAXcHZGVmYXVsdCgAn5aq3wiDAQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgAn5aq3wiDAQdjaGVja2VkAXgoAJ+Wqt8IggECaWQBdyQwYzM3OTRkYS1kNzdlLTQ4ZWYtYTQzZi0wMTRlOTkwOGE5ZTaHn5aq3wiCAQMOYmxvY2tDb250YWluZXIHAJ+Wqt8ImgEDCXBhcmFncmFwaCgAn5aq3wibAQ9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACflqrfCJsBCXRleHRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8ImwENdGV4dEFsaWdubWVudAF3BGxlZnQoAJ+Wqt8ImgECaWQBdyRjZGNlYzk3Yy04NzlkLTQxNzctODg5MC0xZjdhZmU0ODkxZTmHn5aq3wiaAQMOYmxvY2tDb250YWluZXIHAJ+Wqt8IoAEDBXF1b3RlBwCflqrfCKEBBgQAn5aq3wiiAQtIZWxsbyBRdW90ZSgAn5aq3wihAQ9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACflqrfCKEBCXRleHRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8IoAECaWQBdyQ3ZDBhNWRiMS03MjIyLTQ1MzEtYWI4My1mY2ZjN2IzZjcxMmWHn5aq3wigAQMOYmxvY2tDb250YWluZXIHAJ+Wqt8IsQEDCXBhcmFncmFwaCgAn5aq3wiyAQ9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACflqrfCLIBCXRleHRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8IsgENdGV4dEFsaWdubWVudAF3BGxlZnQoAJ+Wqt8IsQECaWQBdyRhMDNkYTZiYS1mZmI1LTRiNWQtOGZmMi1hNzU0OGZiZWFmMDWHn5aq3wixAQMOYmxvY2tDb250YWluZXIHAJ+Wqt8ItwEDDnRvZ2dsZUxpc3RJdGVtBwCflqrfCLgBBgQAn5aq3wi5ARNIZWxsbyBUb2dnbGUgTGlzdCAxKACflqrfCLgBD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8IuAEJdGV4dENvbG9yAXcHZGVmYXVsdCgAn5aq3wi4AQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgAn5aq3wi3AQJpZAF3JGY3M2FmYjQ5LWM4YTctNGZhZi04YThmLTVhZjM4MTJkMDBlM4eflqrfCLcBAw5ibG9ja0NvbnRhaW5lcgcAn5aq3wjRAQMJcGFyYWdyYXBoKACflqrfCNIBD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8I0gEJdGV4dENvbG9yAXcHZGVmYXVsdCgAn5aq3wjSAQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgAn5aq3wjRAQJpZAF3JGU1ODQ0MjY4LTMxMTYtNDIyMS05NzcwLWFlMjNiNGE5OTk5ZIeflqrfCNEBAw5ibG9ja0NvbnRhaW5lcgcAn5aq3wjXAQMQbnVtYmVyZWRMaXN0SXRlbQcAn5aq3wjYAQYEAJ+Wqt8I2QEUTnVtYmVyZWQgTGlzdCBJdGVtIDEoAJ+Wqt8I2AEPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgAn5aq3wjYAQl0ZXh0Q29sb3IBdwdkZWZhdWx0KACflqrfCNgBDXRleHRBbGlnbm1lbnQBdwRsZWZ0KACflqrfCNgBBXN0YXJ0AX8oAJ+Wqt8I1wECaWQBdyQ5NzViODZmYy03OThiLTRmYzUtOWMyNy1mMGY3NmU5MzdjNzeHn5aq3wjXAQMOYmxvY2tDb250YWluZXIHAJ+Wqt8I8wEDEG51bWJlcmVkTGlzdEl0ZW0HAJ+Wqt8I9AEGBACflqrfCPUBFE51bWJlcmVkIExpc3QgSXRlbSAyKACflqrfCPQBD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8I9AEJdGV4dENvbG9yAXcHZGVmYXVsdCgAn5aq3wj0AQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgAn5aq3wj0AQVzdGFydAF/KACflqrfCPMBAmlkAXckMWQ4NWYzNWYtN2U3Ni00OGQ1LTkwNGYtOWZkOGUxZWI1ZDBkh5+Wqt8I8wEDDmJsb2NrQ29udGFpbmVyBwCflqrfCI8CAwlwYXJhZ3JhcGgoAJ+Wqt8IkAIPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgAn5aq3wiQAgl0ZXh0Q29sb3IBdwdkZWZhdWx0KACflqrfCJACDXRleHRBbGlnbm1lbnQBdwRsZWZ0KACflqrfCI8CAmlkAXckMmYzNjFkMGEtNGU5NC00NjlhLTllZWYtYzg1YTI5NDZlMjY3h5+Wqt8IjwIDDmJsb2NrQ29udGFpbmVyBwCflqrfCJUCAw5idWxsZXRMaXN0SXRlbQcAn5aq3wiWAgYEAJ+Wqt8IlwISQnVsbGV0IExpc3QgSXRlbSAxKACflqrfCJYCD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8IlgIJdGV4dENvbG9yAXcHZGVmYXVsdCgAn5aq3wiWAg10ZXh0QWxpZ25tZW50AXcEbGVmdCgAn5aq3wiVAgJpZAF3JDAzZGEyMDJhLTQ0NDQtNDVmMi05Yzc4LTljODU1MDBkNmZkM4eflqrfCJUCAw5ibG9ja0NvbnRhaW5lcgcAn5aq3wiuAgMOYnVsbGV0TGlzdEl0ZW0HAJ+Wqt8IrwIGBACflqrfCLACEkJ1bGxldCBMaXN0IEl0ZW0gMigAn5aq3wivAg9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACflqrfCK8CCXRleHRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8IrwINdGV4dEFsaWdubWVudAF3BGxlZnQoAJ+Wqt8IrgICaWQBdyQ5MjNlNDk3Yy1mODQ4LTRkZmMtYTJlMi02ZTEwYmFlMDU2NWGHn5aq3wiuAgMOYmxvY2tDb250YWluZXIHAJ+Wqt8IxwIDCXBhcmFncmFwaCgAn5aq3wjIAg9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACflqrfCMgCCXRleHRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8IyAINdGV4dEFsaWdubWVudAF3BGxlZnQoAJ+Wqt8IxwICaWQBdyQ3MjBjYjVmOS1mYTlhLTRkMWEtODc0OS03NmQ0ODcyYTE0NjOHn5aq3wjHAgMOYmxvY2tDb250YWluZXIHAJ+Wqt8IzQIDCWNvZGVCbG9jawcAn5aq3wjOAgYGAJ+Wqt8IzwIJdGV4dENvbG9yJHsic3RyaW5nVmFsdWUiOiJyZ2IoMjQ5LCAxMTcsIDEzMSkifYSflqrfCNACBWNvbnN0hp+Wqt8I1QIJdGV4dENvbG9yBG51bGyGn5aq3wjWAgl0ZXh0Q29sb3IkeyJzdHJpbmdWYWx1ZSI6InJnYigyMjUsIDIyOCwgMjMyKSJ9hJ+Wqt8I1wIBIIaflqrfCNgCCXRleHRDb2xvcgRudWxshp+Wqt8I2QIJdGV4dENvbG9yJHsic3RyaW5nVmFsdWUiOiJyZ2IoMTIxLCAxODQsIDI1NSkifYSflqrfCNoCAWGGn5aq3wjbAgl0ZXh0Q29sb3IEbnVsbIaflqrfCNwCCXRleHRDb2xvciR7InN0cmluZ1ZhbHVlIjoicmdiKDIyNSwgMjI4LCAyMzIpIn2En5aq3wjdAgEghp+Wqt8I3gIJdGV4dENvbG9yBG51bGyGn5aq3wjfAgl0ZXh0Q29sb3IkeyJzdHJpbmdWYWx1ZSI6InJnYigyNDksIDExNywgMTMxKSJ9hJ+Wqt8I4AIBPYaflqrfCOECCXRleHRDb2xvcgRudWxshp+Wqt8I4gIJdGV4dENvbG9yJHsic3RyaW5nVmFsdWUiOiJyZ2IoMjI1LCAyMjgsIDIzMikifYSflqrfCOMCASCGn5aq3wjkAgl0ZXh0Q29sb3IEbnVsbIaflqrfCOUCCXRleHRDb2xvciR7InN0cmluZ1ZhbHVlIjoicmdiKDEyMSwgMTg0LCAyNTUpIn2En5aq3wjmAgIxMIaflqrfCOgCCXRleHRDb2xvcgRudWxshp+Wqt8I6QIJdGV4dENvbG9yJHsic3RyaW5nVmFsdWUiOiJyZ2IoMjI1LCAyMjgsIDIzMikifYSflqrfCOoCATuGn5aq3wjrAgl0ZXh0Q29sb3IEbnVsbCgAn5aq3wjOAghsYW5ndWFnZQF3CmphdmFzY3JpcHQoAJ+Wqt8IzQICaWQBdyRjYmNiZjUyMy0zODFmLTRmMzQtOTlmYy0zZmU5MzA3YTFkODKHn5aq3wjNAgMOYmxvY2tDb250YWluZXIHAJ+Wqt8I7wIDCXBhcmFncmFwaCgAn5aq3wjwAg9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACflqrfCPACCXRleHRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8I8AINdGV4dEFsaWdubWVudAF3BGxlZnQoAJ+Wqt8I7wICaWQBdyQ1Y2RkNmU5MC04MzYwLTQ1MTctOWI5MS03OGY4NjMzYWI1ZTCHn5aq3wjvAgMOYmxvY2tDb250YWluZXIHAJ+Wqt8I9QIDB2RpdmlkZXIoAJ+Wqt8I9QICaWQBdyQwOTFhNjY1Zi1hMmJjLTQwYjEtOWVkOS02MGJkMzQ5YWE5NmSHn5aq3wj1AgMOYmxvY2tDb250YWluZXIHAJ+Wqt8I+AIDCXBhcmFncmFwaCgAn5aq3wj5Ag9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACflqrfCPkCCXRleHRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8I+QINdGV4dEFsaWdubWVudAF3BGxlZnQoAJ+Wqt8I+AICaWQBdyRkZGMxNWVjYi1mZmUxLTRiYzktOWVkMy01YWNiOTAxOTVkOGWHn5aq3wj4AgMOYmxvY2tDb250YWluZXIHAJ+Wqt8I/gIDCXBhZ2VCcmVhaygAn5aq3wj+AgJpZAF3JGM4MWE1NGQxLTQyMDEtNDAyOS1iNWIzLTMwNDBhNjA3NzBlM4eflqrfCP4CAwpjb2x1bW5MaXN0BwCflqrfCIEDAwZjb2x1bW4HAJ+Wqt8IggMDDmJsb2NrQ29udGFpbmVyBwCflqrfCIMDAwlwYXJhZ3JhcGgHAJ+Wqt8IhAMGBACflqrfCIUDDUNvbHVtbiAxIFRleHQoAJ+Wqt8IhAMPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgAn5aq3wiEAwl0ZXh0Q29sb3IBdwdkZWZhdWx0KACflqrfCIQDDXRleHRBbGlnbm1lbnQBdwRsZWZ0KACflqrfCIMDAmlkAXckMDJjYzRhNTAtOGQwNS00MzA3LWI3YzctMzZlYjI3ODE1OTNkKACflqrfCIIDAmlkAXckMTNlYjA4Y2QtNTkyOC00Y2ZiLWFkMzAtM2RiZGMwMDgzMjFlKACflqrfCIIDBXdpZHRoAX0Bh5+Wqt8IggMDBmNvbHVtbgcAn5aq3wiZAwMOYmxvY2tDb250YWluZXIHAJ+Wqt8ImgMDCXBhcmFncmFwaAcAn5aq3wibAwYEAJ+Wqt8InAMNQ29sdW1uIDIgVGV4dCgAn5aq3wibAw9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACflqrfCJsDCXRleHRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8ImwMNdGV4dEFsaWdubWVudAF3BGxlZnQoAJ+Wqt8ImgMCaWQBdyQ2NTA5ZGYzZS03MmViLTQ3ZjEtYmNiMC1lNzgyZjM4YTkwODkoAJ+Wqt8ImQMCaWQBdyQwMGEyNmFiMC1kYzI1LTRhZGYtOWQ5MC1iYTNmYTAwM2VmYmIoAJ+Wqt8ImQMFd2lkdGgBfQGHn5aq3wiZAwMGY29sdW1uBwCflqrfCLADAw5ibG9ja0NvbnRhaW5lcgcAn5aq3wixAwMJcGFyYWdyYXBoBwCflqrfCLIDBgQAn5aq3wizAw1Db2x1bW4gMyBUZXh0KACflqrfCLIDD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8IsgMJdGV4dENvbG9yAXcHZGVmYXVsdCgAn5aq3wiyAw10ZXh0QWxpZ25tZW50AXcEbGVmdCgAn5aq3wixAwJpZAF3JDg1NTg5ZTJjLWI1MDQtNGY2OS05MjFlLTkyZjkxNzhmZjFiOYeflqrfCLEDAw5ibG9ja0NvbnRhaW5lcgcAn5aq3wjFAwMJcGFyYWdyYXBoKACflqrfCMYDD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8IxgMJdGV4dENvbG9yAXcHZGVmYXVsdCgAn5aq3wjGAw10ZXh0QWxpZ25tZW50AXcEbGVmdCgAn5aq3wjFAwJpZAF3JGNlMzdkMzJhLTUwZjgtNDBlZi1iNWVjLTA0MWVkYTMzNWY5NSgAn5aq3wiwAwJpZAF3JDg5NTg1NWU3LWMyMGItNDk5Ny04YzkyLWIyNmUyMDc4Nzc2MCgAn5aq3wiwAwV3aWR0aAF9ASgAn5aq3wiBAwJpZAF3JDc5NDIzYTU3LTFlNWEtNDQ4My04YmY5LTQzMzhmYzY1ZTVjMYeflqrfCIEDAw5ibG9ja0NvbnRhaW5lcgcAn5aq3wjOAwMHY2FsbG91dCgAn5aq3wjPAw10ZXh0QWxpZ25tZW50AXcEbGVmdCgAn5aq3wjPAw9iYWNrZ3JvdW5kQ29sb3IBdwZ5ZWxsb3coAJ+Wqt8IzwMFZW1vamkBdwTwn5KhKACflqrfCM4DAmlkAXckYjJhYjhjN2EtYzcwZC00Mzc5LTg4ZTUtZWI5NzQ3Njc2Yjkyh5+Wqt8IzgMDDmJsb2NrQ29udGFpbmVyBwCflqrfCNQDAwlwYXJhZ3JhcGgoAJ+Wqt8I1QMPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgAn5aq3wjVAwl0ZXh0Q29sb3IBdwdkZWZhdWx0KACflqrfCNUDDXRleHRBbGlnbm1lbnQBdwRsZWZ0KACflqrfCNQDAmlkAXckODg0NTBmY2MtMjUwZC00Y2ZkLWE3NmQtNzgzMWExMGQyNjE0h5+Wqt8I1AMDDmJsb2NrQ29udGFpbmVyBwCflqrfCNoDAwlwYXJhZ3JhcGgoAJ+Wqt8I2wMPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgAn5aq3wjbAwl0ZXh0Q29sb3IBdwdkZWZhdWx0KACflqrfCNsDDXRleHRBbGlnbm1lbnQBdwRsZWZ0KACflqrfCNoDAmlkAXckNjc2YTQ3NTItZmQ4OC00OTY1LWIzYmUtZGRmNzE4ZWZkM2Rih5+Wqt8I2gMDDmJsb2NrQ29udGFpbmVyBwCflqrfCOADAwV0YWJsZQcAn5aq3wjhAwMIdGFibGVSb3cHAJ+Wqt8I4gMDCXRhYmxlQ2VsbAcAn5aq3wjjAwMOdGFibGVQYXJhZ3JhcGgHAJ+Wqt8I5AMGBgCflqrfCOUDBGJvbGQCe32En5aq3wjmAwtCUklE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b2xvcgF3B2RlZmF1bHQoAJ+Wqt8IgwgNdGV4dEFsaWdubWVudAF3BGxlZnQoAJ+Wqt8IgwgHY29sc3BhbgF9ASgAn5aq3wiDCAdyb3dzcGFuAX0BKACflqrfCIMICGNvbHdpZHRoAXUBfT+Hn5aq3wiDCAMJdGFibGVDZWxsBwCflqrfCJAIAw50YWJsZVBhcmFncmFwaAcAn5aq3wiRCAYEAJ+Wqt8IkggEMTQsNCgAn5aq3wiQCAl0ZXh0Q29sb3IBdwdkZWZhdWx0KACflqrfCJAID2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8IkAgNdGV4dEFsaWdubWVudAF3BGxlZnQoAJ+Wqt8IkAgHY29sc3BhbgF9ASgAn5aq3wiQCAdyb3dzcGFuAX0BKACflqrfCJAICGNvbHdpZHRoAXUBfYcBKACflqrfCOEDCXRleHRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8I4AMCaWQBdyRmMmQwYmNjZS03YmI5LTQ4NzUtODFiMC1kYjJiOTk0NjkwNTSHn5aq3wjgAwMOYmxvY2tDb250YWluZXIHAJ+Wqt8InwgDCXBhcmFncmFwaCgAn5aq3wigCA9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACflqrfCKAICXRleHRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8IoAgNdGV4dEFsaWdubWVudAF3BGxlZnQoAJ+Wqt8InwgCaWQBdyRhYTJiNWQ2MC1mMDFhLTRmNGQtODkxNC00ZTljY2FlNDgwYTSHn5aq3wifCAMOYmxvY2tDb250YWluZXIHAJ+Wqt8IpQgDCXBhcmFncmFwaAcAn5aq3wimCAMYaW50ZXJsaW5raW5nU2VhcmNoSW5saW5lKACflqrfCKcIB3RyaWdnZXIBdwEvKACflqrfCKcICGRpc2FibGVkAXiHn5aq3winCAMWaW50ZXJsaW5raW5nTGlua0lubGluZSgAn5aq3wiqCAVkb2NJZAF3JDAyMmQwNDhjLTZkMDctNGU1OC1iM2ZjLTljNDNhMjg3MTJlOSgAn5aq3wiqCAV0aXRsZQF3D1Rlc3QgUmVncmVzc2lvbigAn5aq3wimCA9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACflqrfCKYICXRleHRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8IpggNdGV4dEFsaWdubWVudAF3BGxlZnSHn5aq3wimCAMKYmxvY2tHcm91cAcAn5aq3wiwCAMOYmxvY2tDb250YWluZXIHAJ+Wqt8IsQgDBWltYWdlKACflqrfCLIIDXRleHRBbGlnbm1lbnQBdwRsZWZ0KACflqrfCLIID2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8IsggEbmFtZQF3ACgAn5aq3wiyCAN1cmwBdxZodHRwOi8vbG9jYWxob3N0OjMwMDAvKACflqrfCLIIB2NhcHRpb24BdwAoAJ+Wqt8IsggLc2hvd1ByZXZpZXcBeCgAn5aq3wiyCAxwcmV2aWV3V2lkdGgBfygAn5aq3wixCAJpZAF3JDk1Y2Q0NDI2LTA4YTgtNGRlZC1iNTcxLWZlYWUzYmMyMzA0ZYeflqrfCLEIAw5ibG9ja0NvbnRhaW5lcgcAn5aq3wi7CAMJcGFyYWdyYXBoKACflqrfCLwID2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8IvAgJdGV4dENvbG9yAXcHZGVmYXVsdCgAn5aq3wi8CA10ZXh0QWxpZ25tZW50AXcEbGVmdCgAn5aq3wi7CAJpZAF3JDY1MTg5OWZlLWUyOWMtNGU0ZC04Njk0LWJmOWEzZWQ0MDVmZigAn5aq3wilCAJpZAF3JGZiN2UzMTRmLTA3YjEtNDFhZi04OTIyLWNmNTM3YjRkYjNjYYeflqrfCKUIAw5ibG9ja0NvbnRhaW5lcgcAn5aq3wjCCAMHaGVhZGluZwcAn5aq3wjDCAYGAJ+Wqt8IxAgEYm9sZAJ7fYSflqrfCMUIEkhlbGxvIFRpdGxlIFRvZ2dsZYaflqrfCNcIBGJvbGQEbnVsbCgAn5aq3wjDCA9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACflqrfCMMICXRleHRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8IwwgNdGV4dEFsaWdubWVudAF3BGxlZnQoAJ+Wqt8IwwgFbGV2ZWwBfQEoAJ+Wqt8IwwgMaXNUb2dnbGVhYmxlAXgoAJ+Wqt8IwggCaWQBdyRmZTMyZGNiOC0zMjVjLTQyZTEtYjQ2Zi04NDVlZTUxMmI1NGWHn5aq3wjCCAMOYmxvY2tDb250YWluZXIHAJ+Wqt8I3wgDCXBhcmFncmFwaCgAn5aq3wjgCA9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACflqrfCOAICXRleHRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8I4AgNdGV4dEFsaWdubWVudAF3BGxlZnQoAJ+Wqt8I3wgCaWQBdyRiNmFlOWZhNS04MWJhLTQzNmMtOWYyMC04NTk4MGNkNTUxYjWHn5aq3wjfCAMOYmxvY2tDb250YWluZXIHAJ+Wqt8I5QgDB2hlYWRpbmcHAJ+Wqt8I5ggGBgCflqrfCOcIBGJvbGQCe32En5aq3wjoCA1IZWxsbyB0aXRsZSA0hp+Wqt8I9QgEYm9sZARudWxsKACflqrfCOYID2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8I5ggJdGV4dENvbG9yAXcHZGVmYXVsdCgAn5aq3wjmCA10ZXh0QWxpZ25tZW50AXcEbGVmdCgAn5aq3wjmCAVsZXZlbAF9BCgAn5aq3wjmCAxpc1RvZ2dsZWFibGUBeSgAn5aq3wjlCAJpZAF3JDU5MWE1YjUwLWZjZjAtNDJjYi1iNDdiLTlhZDc5ZjUzN2ZkMYeflqrfCOUIAw5ibG9ja0NvbnRhaW5lcgcAn5aq3wj9CAMJcGFyYWdyYXBoKACflqrfCP4ID2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8I/ggJdGV4dENvbG9yAXcHZGVmYXVsdCgAn5aq3wj+CA10ZXh0QWxpZ25tZW50AXcEbGVmdCgAn5aq3wj9CAJpZAF3JDRmNDkxYzE3LTk4ZDktNDdkOS04ZmYxLTg4MDdhZmQzMGY3ZYeflqrfCP0IAw5ibG9ja0NvbnRhaW5lcgcAn5aq3wiDCQMHaGVhZGluZwcAn5aq3wiECQYGAJ+Wqt8IhQkEYm9sZAJ7fYSflqrfCIYJDUhlbGxvIHRpdGxlIDWGn5aq3wiTCQRib2xkBG51bGwoAJ+Wqt8IhAkPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgAn5aq3wiECQl0ZXh0Q29sb3IBdwdkZWZhdWx0KACflqrfCIQJDXRleHRBbGlnbm1lbnQBdwRsZWZ0KACflqrfCIQJBWxldmVsAX0FKACflqrfCIQJDGlzVG9nZ2xlYWJsZQF5KACflqrfCIMJAmlkAXckNTk0OTQ1MzUtODQxNS00MmU0LTk2YWMtNjQxYjFiY2E1NmFhh5+Wqt8IgwkDDmJsb2NrQ29udGFpbmVyBwCflqrfCJsJAwlwYXJhZ3JhcGgoAJ+Wqt8InAkPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgAn5aq3wicCQl0ZXh0Q29sb3IBdwdkZWZhdWx0KACflqrfCJwJDXRleHRBbGlnbm1lbnQBdwRsZWZ0KACflqrfCJsJAmlkAXckMDZhOTcxMmUtYjYwZi00NzYzLWI2ZmItM2M2N2FjYTBmNTY4h5+Wqt8ImwkDDmJsb2NrQ29udGFpbmVyBwCflqrfCKEJAwdoZWFkaW5nBwCflqrfCKIJBgYAn5aq3wijCQRib2xkAnt9hJ+Wqt8IpAkNSGVsbG8gdGl0bGUgNoaflqrfCLEJBGJvbGQEbnVsbCgAn5aq3wiiCQ9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACflqrfCKIJCXRleHRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8IogkNdGV4dEFsaWdubWVudAF3BGxlZnQoAJ+Wqt8IogkFbGV2ZWwBfQYoAJ+Wqt8IogkMaXNUb2dnbGVhYmxlAXkoAJ+Wqt8IoQkCaWQBdyRhMTQzMjQ2MS00MWUwLTQ3YTAtYjEzZS01ZmM3MzI0YmNhMjKHn5aq3wihCQMOYmxvY2tDb250YWluZXIHAJ+Wqt8IuQkDCXBhcmFncmFwaCgAn5aq3wi6CQ9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACflqrfCLoJCXRleHRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8IugkNdGV4dEFsaWdubWVudAF3BGxlZnQoAJ+Wqt8IuQkCaWQBdyQzOWIwNzcxNy1iOWRiLTQ0OTMtOGVmMC1hYzE4MTE4Mzc3M2OHn5aq3wi5CQMOYmxvY2tDb250YWluZXIHAJ+Wqt8IvwkDCXBhcmFncmFwaAcAn5aq3wjACQYEAJ+Wqt8IwQkN8J+nmeKAjeKZgu+4j4aflqrfCMYJCXRleHRDb2xvciF7InN0cmluZ1ZhbHVlIjoicmdiKDY5LCA2OSwgODgpIn2Gn5aq3wjHCQ9iYWNrZ3JvdW5kQ29sb3IkeyJzdHJpbmdWYWx1ZSI6InJnYigyNTUsIDI1NSwgMjU1KSJ9hJ+Wqt8IyAk88J+Yg/Cfjonwn5qA8J+Zi+KAjeKZgO+4j/Cfp5Hwn4+/4oCN4p2k77iP4oCN8J+Si+KAjfCfp5Hwn4++hp+Wqt8I4gkJdGV4dENvbG9yBG51bGyGn5aq3wjjCQ9iYWNrZ3JvdW5kQ29sb3IEbnVsbISflqrfCOQJDCBNYWdpYyBlbW9qaSgAn5aq3wjACQ9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACflqrfCMAJCXRleHRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8IwAkNdGV4dEFsaWdubWVudAF3BGxlZnQoAJ+Wqt8IvwkCaWQBdyRlNWJlN2UwYi0wYTBiLTQzMzQtYTE4Ni00MDJlMDUwYjc2YWGHn5aq3wi/CQMOYmxvY2tDb250YWluZXIHAJ+Wqt8I9QkDB2hlYWRpbmcHAJ+Wqt8I9gkGBgCflqrfCPcJBGJvbGQCe32En5aq3wj4CRdjb3B5L3Bhc3Rpbmcgb3V0IG9mIGRvY4aflqrfCI8KBGJvbGQEbnVsbCgAn5aq3wj2CQ9iYWNrZ3JvdW5kQ29sb3IBdw9yZ2IoMTMsIDE3LCAyMykoAJ+Wqt8I9gkJdGV4dENvbG9yAXcScmdiKDI0MCwgMjQ2LCAyNTIpKACflqrfCPYJDXRleHRBbGlnbm1lbnQBdwRsZWZ0KACflqrfCPYJBWxldmVsAX0BKACflqrfCPYJDGlzVG9nZ2xlYWJsZQF5KACflqrfCPUJAmlkAXckY2FlZGEwNWMtNDFlZi00MjlhLThjOWItNDc5NDcyYTZlZGE1h5+Wqt8I9QkDDmJsb2NrQ29udGFpbmVyBwCflqrfCJcKAwlwYXJhZ3JhcGgoAJ+Wqt8ImAoPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgAn5aq3wiYCgl0ZXh0Q29sb3IBdwdkZWZhdWx0KACflqrfCJgKDXRleHRBbGlnbm1lbnQBdwRsZWZ0KACflqrfCJcKAmlkAXckMDQzMDJiNWUtYzJhMy00MjgxLTk2NTYtM2M3ZTU0MjVhZWFkh5+Wqt8IlwoDDmJsb2NrQ29udGFpbmVyBwCflqrfCJ0KAwVpbWFnZSgAn5aq3wieCg10ZXh0QWxpZ25tZW50AXcEbGVmdCgAn5aq3wieCg9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACflqrfCJ4KBG5hbWUBdwh0ZXN0LnN2ZygAn5aq3wieCgN1cmwBd3VodHRwOi8vbG9jYWxob3N0OjgwODMvbWVkaWEvYTQxOGE5NjQtYTZmOS00YmE2LTkyYzEtYmY0Njc2NmMzMTk1L2F0dGFjaG1lbnRzLzM4ZTFmNTFhLTgzZTAtNDUzNi04ZmIwLWM3ZjFhMDMxNTUwZC5zdmcoAJ+Wqt8IngoHY2FwdGlvbgF3ACgAn5aq3wieCgtzaG93UHJldmlldwF4KACflqrfCJ4KDHByZXZpZXdXaWR0aAF/KACflqrfCJ0KAmlkAXckMWUwZGE1YjAtMzNjYi00MDcwLTgzYmQtODQxNjNkYzY0MGJmh5+Wqt8InQoDDmJsb2NrQ29udGFpbmVyBwCflqrfCKcKAwVpbWFnZSgAn5aq3wioCg10ZXh0QWxpZ25tZW50AXcEbGVmdCgAn5aq3wioCg9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACflqrfCKgKBG5hbWUBdxhsb2dvLXN1aXRlLW51bWVyaXF1ZS5wbmcoAJ+Wqt8IqAoDdXJsAXd1aHR0cDovL2xvY2FsaG9zdDo4MDgzL21lZGlhL2E0MThhOTY0LWE2ZjktNGJhNi05MmMxLWJmNDY3NjZjMzE5NS9hdHRhY2htZW50cy8wODZjNTM3Yi1jMGVkLTQ5ZGUtYWE3Yy01YjhjOTNhMWE4MjkucG5nKACflqrfCKgKB2NhcHRpb24BdwAoAJ+Wqt8IqAoLc2hvd1ByZXZpZXcBeCgAn5aq3wioCgxwcmV2aWV3V2lkdGgBfygAn5aq3winCgJpZAF3JGY0NTUyYjhjLWYxYzktNDlhZS04ZTA1LTczMzM1NTJiMjk0ZYeflqrfCKcKAw5ibG9ja0NvbnRhaW5lcgcAn5aq3wixCgMJcGFyYWdyYXBoKACflqrfCLIKD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAJ+Wqt8IsgoJdGV4dENvbG9yAXcHZGVmYXVsdCgAn5aq3wiyCg10ZXh0QWxpZ25tZW50AXcEbGVmdCgAn5aq3wixCgJpZAF3JDBkMmU3N2EyLWRkNWMtNDFlMC1hN2E2LTEyMjA1ODBlYzRmYgLJoobnCQMAAggFFQaflqrfCAOfCAa7CAedCho=" \ No newline at end of file diff --git a/src/frontend/apps/e2e/__tests__/app-impress/assets/doc-export-regressions.pdf b/src/frontend/apps/e2e/__tests__/app-impress/assets/doc-export-regressions.pdf index 60a159f5..42f9f0ac 100644 Binary files a/src/frontend/apps/e2e/__tests__/app-impress/assets/doc-export-regressions.pdf and b/src/frontend/apps/e2e/__tests__/app-impress/assets/doc-export-regressions.pdf differ diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index 7933931f..e0ea8f59 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -890,6 +890,9 @@ test.describe('Doc Editor', () => { await expect(interlinkChild.locator('svg').first()).toBeHidden(); await interlinkChild.click(); + // wait for navigation to complete + await page.waitForTimeout(1000); + await verifyDocName(page, docChild2); await editor.click(); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts index 5850b218..03487205 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts @@ -35,9 +35,6 @@ test.describe('Doc Export', () => { await expect( page.getByText(/Download your document in a \.docx, \.odt.*format\./i), ).toBeVisible(); - await expect( - page.getByRole('combobox', { name: 'Template' }), - ).toBeVisible(); await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible(); await expect( page.getByRole('button', { @@ -236,20 +233,6 @@ test.describe('Doc Export', () => { }) .click(); - await page - .getByRole('combobox', { - name: 'Template', - }) - .click(); - - await page - .getByRole('option', { - name: 'Demo Template', - }) - .click({ - delay: 100, - }); - await new Promise((resolve) => setTimeout(resolve, 1000)); await expect(page.getByTestId('doc-export-download-button')).toBeVisible(); @@ -464,6 +447,8 @@ export const overrideDocContent = async ({ await verifyDocName(page, randomDoc); + await page.waitForTimeout(1000); + // Add Image SVG await page.keyboard.press('Enter'); const { suggestionMenu } = await openSuggestionMenu({ page }); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts index 8744633a..f5e90c81 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts @@ -27,47 +27,32 @@ test.describe('Doc Tree', () => { const docTree = page.getByTestId('doc-tree'); // Create first sub page - const firstResponsePromise = page.waitForResponse( - (response) => - response.url().includes('/documents/') && - response.url().includes('/children/') && - response.request().method() === 'POST', - ); - await clickOnAddRootSubPage(page); - const firstResponse = await firstResponsePromise; - expect(firstResponse.ok()).toBeTruthy(); - await updateDocTitle(page, 'first'); - - const secondResponsePromise = page.waitForResponse( - (response) => - response.url().includes('/documents/') && - response.url().includes('/children/') && - response.request().method() === 'POST', - ); + await updateDocTitle(page, 'first move'); // Create second sub page await clickOnAddRootSubPage(page); - const secondResponse = await secondResponsePromise; - expect(secondResponse.ok()).toBeTruthy(); - await updateDocTitle(page, 'second'); + await updateDocTitle(page, 'second move'); - const secondSubPageJson = await secondResponse.json(); - const firstSubPageJson = await firstResponse.json(); - - const firstSubPageItem = docTree - .getByTestId(`doc-sub-page-item-${firstSubPageJson.id}`) - .first(); - - const secondSubPageItem = docTree - .getByTestId(`doc-sub-page-item-${secondSubPageJson.id}`) - .first(); + const firstSubPageItem = docTree.getByText('first move').first(); + const secondSubPageItem = docTree.getByText('second move').first(); // check that the sub pages are visible in the tree await expect(firstSubPageItem).toBeVisible(); await expect(secondSubPageItem).toBeVisible(); - // get the bounding boxes of the sub pages + // Check the position of the sub pages + const allSubPageItems = await docTree + .getByTestId(/^doc-sub-page-item/) + .all(); + + expect(allSubPageItems.length).toBe(2); + + // Check that elements are in the correct order + await expect(allSubPageItems[0].getByText('first move')).toBeVisible(); + await expect(allSubPageItems[1].getByText('second move')).toBeVisible(); + + // Will move the first sub page to the second position const firstSubPageBoundingBox = await firstSubPageItem.boundingBox(); const secondSubPageBoundingBox = await secondSubPageItem.boundingBox(); @@ -75,10 +60,9 @@ test.describe('Doc Tree', () => { expect(secondSubPageBoundingBox).toBeDefined(); if (!firstSubPageBoundingBox || !secondSubPageBoundingBox) { - throw new Error('Impossible de déterminer la position des éléments'); + throw new Error('unable to determine the position of the elements'); } - // move the first sub page to the second position await page.mouse.move( firstSubPageBoundingBox.x + firstSubPageBoundingBox.width / 2, firstSubPageBoundingBox.y + firstSubPageBoundingBox.height / 2, @@ -105,24 +89,19 @@ test.describe('Doc Tree', () => { await expect(firstSubPageItem).toBeVisible(); await expect(secondSubPageItem).toBeVisible(); - // Check the position of the sub pages - const allSubPageItems = await docTree + // Check that elements are in the correct order + const allSubPageItemsAfterReload = await docTree .getByTestId(/^doc-sub-page-item/) .all(); - expect(allSubPageItems.length).toBe(2); + expect(allSubPageItemsAfterReload.length).toBe(2); - // Check that the first element has the ID of the second sub page after the drag and drop - await expect(allSubPageItems[0]).toHaveAttribute( - 'data-testid', - `doc-sub-page-item-${secondSubPageJson.id}`, - ); - - // Check that the second element has the ID of the first sub page after the drag and drop - await expect(allSubPageItems[1]).toHaveAttribute( - 'data-testid', - `doc-sub-page-item-${firstSubPageJson.id}`, - ); + await expect( + allSubPageItemsAfterReload[0].getByText('second move'), + ).toBeVisible(); + await expect( + allSubPageItemsAfterReload[1].getByText('first move'), + ).toBeVisible(); }); test('it detaches a document', async ({ page, browserName }) => { diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts index 7b6bd2e7..6854e0b4 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -8,6 +8,8 @@ export const CONFIG = { CRISP_WEBSITE_ID: null, COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/', COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true, + CONVERSION_FILE_EXTENSIONS_ALLOWED: ['.docx', '.md'], + CONVERSION_FILE_MAX_SIZE: 20971520, ENVIRONMENT: 'development', FRONTEND_CSS_URL: null, FRONTEND_JS_URL: null, @@ -160,7 +162,7 @@ export const verifyDocName = async (page: Page, docName: string) => { await expect( page.getByRole('textbox', { name: 'Document title' }), ).toContainText(docName, { - timeout: 1000, + timeout: 3000, }); } catch { await expect(page.getByRole('heading', { name: docName })).toBeVisible(); diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index 71646706..cfb372cb 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -55,13 +55,14 @@ "i18next": "25.7.3", "i18next-browser-languagedetector": "8.2.0", "idb": "8.0.3", - "lodash": "4.17.21", + "lodash": "4.17.23", "luxon": "3.7.2", "next": "15.5.9", "posthog-js": "1.312.0", "react": "*", "react-aria-components": "1.14.0", "react-dom": "*", + "react-dropzone": "14.3.8", "react-i18next": "16.5.1", "react-intersection-observer": "10.0.0", "react-resizable-panels": "3.0.6", diff --git a/src/frontend/apps/impress/src/api/helpers.tsx b/src/frontend/apps/impress/src/api/helpers.tsx index cbc4d0b3..991c5176 100644 --- a/src/frontend/apps/impress/src/api/helpers.tsx +++ b/src/frontend/apps/impress/src/api/helpers.tsx @@ -20,7 +20,7 @@ export type DefinedInitialDataInfiniteOptionsAPI< QueryKey, TPageParam >; - +export type UseInfiniteQueryResultAPI = InfiniteData; export type InfiniteQueryConfig = Omit< DefinedInitialDataInfiniteOptionsAPI, 'queryKey' | 'initialData' | 'getNextPageParam' | 'initialPageParam' diff --git a/src/frontend/apps/impress/src/components/Icon.tsx b/src/frontend/apps/impress/src/components/Icon.tsx index 39234505..e5c63806 100644 --- a/src/frontend/apps/impress/src/components/Icon.tsx +++ b/src/frontend/apps/impress/src/components/Icon.tsx @@ -1,21 +1,34 @@ import clsx from 'clsx'; +import React from 'react'; import { css } from 'styled-components'; import { Text, TextType } from '@/components'; -type IconProps = TextType & { +type IconBase = TextType & { disabled?: boolean; +}; + +type IconMaterialProps = IconBase & { iconName: string; variant?: 'filled' | 'outlined' | 'symbols-outlined'; + icon?: never; }; + +type IconSVGProps = IconBase & { + icon: React.ReactNode; + iconName?: never; + variant?: never; +}; + export const Icon = ({ className, - iconName, disabled, + iconName, + icon, variant = 'outlined', $theme = 'neutral', ...textProps -}: IconProps) => { +}: IconMaterialProps | IconSVGProps) => { const hasLabel = 'aria-label' in textProps || 'aria-labelledby' in textProps; const ariaHidden = 'aria-hidden' in textProps ? textProps['aria-hidden'] : !hasLabel; @@ -24,15 +37,15 @@ export const Icon = ({ - {iconName} + {iconName ?? icon} ); }; diff --git a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx index df846e67..6181ecab 100644 --- a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx @@ -18,6 +18,8 @@ export interface ConfigResponse { AI_FEATURE_ENABLED?: boolean; COLLABORATION_WS_URL?: string; COLLABORATION_WS_NOT_CONNECTED_READY_ONLY?: boolean; + CONVERSION_FILE_EXTENSIONS_ALLOWED: string[]; + CONVERSION_FILE_MAX_SIZE: number; CRISP_WEBSITE_ID?: string; ENVIRONMENT: string; FRONTEND_CSS_URL?: string; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/SearchPage.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/SearchPage.tsx index 47428e75..8027b2e4 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/SearchPage.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/SearchPage.tsx @@ -122,8 +122,8 @@ export const SearchPage = ({ const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); - // Keep the trigger character ('@' or '/') in the editor when closing with Escape - closeSearch(trigger); + // Keep the trigger character and typed text in the editor when closing with Escape + closeSearch(`${trigger}${search}`); } else if (e.key === 'Backspace' && search.length === 0) { e.preventDefault(); closeSearch(''); diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/api/useExport.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/api/useExport.tsx deleted file mode 100644 index 1be23ff5..00000000 --- a/src/frontend/apps/impress/src/features/docs/doc-export/api/useExport.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useMutation } from '@tanstack/react-query'; - -import { APIError, errorCauses, fetchAPI } from '@/api'; - -interface CreateExportParams { - templateId: string; - body: string; - body_type: 'html' | 'markdown'; - format: 'pdf' | 'docx'; -} - -export const createExport = async ({ - templateId, - body, - body_type, - format, -}: CreateExportParams): Promise => { - const response = await fetchAPI( - `templates/${templateId}/generate-document/`, - { - method: 'POST', - body: JSON.stringify({ - body, - body_type, - format, - }), - }, - ); - - if (!response.ok) { - throw new APIError( - 'Failed to export the document', - await errorCauses(response), - ); - } - - return await response.blob(); -}; - -export function useExport() { - return useMutation({ - mutationFn: createExport, - }); -} diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/api/useTemplates.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/api/useTemplates.tsx deleted file mode 100644 index 984393d6..00000000 --- a/src/frontend/apps/impress/src/features/docs/doc-export/api/useTemplates.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { - DefinedInitialDataInfiniteOptions, - InfiniteData, - QueryKey, - useInfiniteQuery, -} from '@tanstack/react-query'; - -import { APIError, APIList, errorCauses, fetchAPI } from '@/api'; - -import { Template } from '../types'; - -export enum TemplatesOrdering { - BY_CREATED_ON = 'created_at', - BY_CREATED_ON_DESC = '-created_at', -} - -export type TemplatesParams = { - ordering: TemplatesOrdering; -}; -type TemplatesAPIParams = TemplatesParams & { - page: number; -}; - -type TemplatesResponse = APIList