diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index d46bceae41..8148eb5a0d 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -1,7 +1,7 @@ --- name: "Bug report \U0001F41B" about: Create a report to help us improve -labels: ':bug: bug' +labels: 'bug' --- diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md index f7aadd9f73..00b8789785 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -1,7 +1,7 @@ --- name: "Feature request \U0001F4A1" about: Suggest an idea for this project -labels: ':bulb: feature' +labels: 'feature' --- diff --git a/docs/src/debugging.rst b/docs/src/debugging.rst index 6423a3d49f..0e02062648 100644 --- a/docs/src/debugging.rst +++ b/docs/src/debugging.rst @@ -1,9 +1,12 @@ -******************************** -Debugging (Django Debug Toolbar) -******************************** +********* +Debugging +********* -We use the :doc:`django-debug-toolbar:index` to debug the CMS. -When :setting:`django:DEBUG` is ``True``, on every HTML page a sidebar is rendered which contains +Django Debug Toolbar +==================== + +We use the :doc:`django-debug-toolbar:index` to debug the CMS in the browser. +When :setting:`django:DEBUG` is ``True``, i.e. the CMS is run in debugging mode, usually through use of the ``run.sh`` script, on every HTML page a sidebar is rendered which contains additional information about the current request, e.g. code execution times, database queries and profiling info. When the sidebar is collapsed, you can show it by clicking on the "DJDT" button somewhere along the right edge of the screen: @@ -16,3 +19,263 @@ In order to use this for JSON views, you have to append ``?debug`` to the URL, e http://localhost:8000/api/v3/augsburg/de/pages?debug (See :class:`~integreat_cms.api.middleware.json_debug_toolbar_middleware.JsonDebugToolbarMiddleware` for reference.) + +Debugging in various editors and IDEs +===================================== + +For more in depth debugging and integration of the debugging process with your editor or IDE, some setup is likely to be required with said editor. +While multiple and often times editor-specific approaches to this exist, we recommend sticking with the `Debug Adapter Protocol `_, or *DAP* for short. +As the name suggests, this is a set of protocols which have implementations both for many editors, and for many languages. +DAP functions as an abstraction layer between whatever generic debugging facilities are implemented in the editor, and the actual language-specific debugger. +This allows the latter one to be implemented in an entirely editor-agnostic way. +For the purposes of the Integreat project, it should also be mentioned that the Python DAP implementation, `debugpy `_, has an option for `debugging django applications `_ built-in, +significantly reducing the setup effort. + +Generally speaking, the steps to setting up a DAP debugger with Integreat look like this: + +#. Install the necessary extensions/plugins/add-ons/..., i.e. the DAP itself, ``debugpy``, and possibly additional resources like a UI for DAP. +#. Configure the DAP executable or server, telling DAP what program will do the actual debugging. In our case, this is ``debugpy``. +#. Add a so-called DAP configuration. This tells DAP how to interact with our project. + +It is DAP's task to connect these two parts of the puzzle. +Below, you can find more specific instructions for configuring your preferred editor or IDE. +Note that ``debugpy`` itself is included in the ``dev`` dependencies in our ``pyproject.toml``, so you do not have to install it separately. + +VSCodium +^^^^^^^^ +Coming soon(TM)? + +Neovim +^^^^^^ + +Neovim enjoys excellent DAP support through the `nvim-dap `_ plugin. +Further, there also exists `nvim-dap-python `_, which handles step two of the three steps listed above, taking care of configuring the integration with ``debugpy``. + +The following instructions assume that you are using `lazy.nvim `_ as your neovim plugin manager. +They are easily adaptable for different plugin managers though, with all the linked projects providing instructions for multiple different plugin managers. + +DAP & DAP-UI +------------ + +Add ``nvim-dap`` to your installed plugins: + +.. code-block:: lua + + "mfussenegger/nvim-dap", + +It is also highly recommended to install ``nvim-dap-ui`` alongside it, since DAP does not come with a UI of its own: + +.. code-block:: lua + + { + "rcarriga/nvim-dap-ui", + dependencies = { + "mfussenegger/nvim-dap", + "nvim-neotest/nvim-nio", + }, + } + +Optionally, you can also install ``nvim-dap-virtual-text``, which will place variable values next the variable definitions as virtual text (see `the usage section <#usage-example-pageformview>`_ for how this looks): + +.. code-block:: lua + + "theHamsta/nvim-dap-virtual-text", + +All of these plugins come with their own set of configuration options, mostly concerned with key binds and visual changes. Check their respective ``README`` file for more. + +Configure ``debugpy`` +--------------------- + +While you `can do this manually `_ as well, there is virtually no benefit to handling this manually compared to using `nvim-dap-python `_. + +First, add the plugin: + +.. code-block:: lua + + "mfussenegger/nvim-dap-python", + +Then initialize the plugin from anywhere in your neovim config: + +.. code-block:: lua + + require("dap-python").setup("python") + +Note that this expects ``python`` to be a valid command, and to point to the *correct* python version. +Most likely, this is the case already. You can check this by running ``which python`` when inside the Integreat project and having the Python virtual environment active. +It should return ``/home//<...>/integreat-cms/.venv/bin/python``. +If this is not the case for you, you can also pass an absolute path to the ``setup()`` function, although this means that your DAP setup is less flexible, since it is then bound to the Integreat project's Python installation explicitly. + +Configure the integration with Django +------------------------------------- + +The config below contains comments which can hopefully help you customize these settings, if so desired: + +.. code-block:: lua + + table.insert(require('dap').configurations.python, { + name = 'Launch Django DAP', -- you can freely change this + type = 'python', -- this must be "python". Will not work otherwise + request = 'launch', -- either "launch" a new debugging session, or "attach" to one you've started yourself. Recommended to leave as "launch" + program = vim.loop.cwd() .. "/.venv/bin/django-admin", -- see explanation below + args = { "runserver", "--noreload" }, -- also see explanation below + console = "integratedTerminal", -- can also be "internalConsole" or "externalTerminal", but this works best with nvim-dap-ui + django = true, -- probably obvious :) + justMyCode = true, -- will only debug our own code. Set to "false" in order to also debug python standard libraries etc + env = { -- required environment variables + DJANGO_SETTINGS_MODULE = "integreat_cms.core.settings", + INTEGREAT_CMS_DEBUG = "true", + INTEGREAT_CMS_SECRET_KEY = "dummy", + }, + }) + +Two options need special highlighting. ``program`` points to the script that should be run in order to start the CMS. +You might have expected this to be ``./tools/integreat-cms-cli`` or similar, but ``debugpy`` expects this to be a Python file. +As an alternative, you can swap ``vim.loop.cwd() .. "/.venv/bin/django-admin"`` out with ``vim.loop.cwd() .. "/.venv/bin/integreat-cms-cli"``. +These should work the same, but in case you are working with Django projects beyond Integreat (like Lunes or the Compass), ``django-admin`` would make the config work with all of those projects. + +Second, the arguments passed in the ``args`` option are mandatory. You can add more (see the documentation for ``debugpy``), but without these, debugging will not work. + +Set up key binds (optional) +--------------------------- + +These are up to you. You could, for example, use: + +.. code-block:: lua + + { + action = "lua require('dap').set_breakpoint(vim.fn.input('Breakpoint condition: '))", + key = "dB", + mode = "n", + options = { desc = "Breakpoint Condition", silent = true }, + }, + { + action = "DapToggleBreakpoint", + key = "db", + mode = "n", + options = { desc = "Toggle Breakpoint", silent = true }, + }, + { + action = "DapContinue", + key = "dr", + mode = "n", + options = { desc = "Start or Continue", silent = true }, + }, + { + action = "lua require('dapui').toggle()", + key = "du", + mode = "n", + options = { desc = "Dap UI", silent = true }, + }, + + +Usage example: ``PageFormView`` +------------------------------- + +We will round off the debugger setup for neovim with a practical example. +Let's say we suspect a bug somewhere in the ``get`` method of the ``PageFormView``. +Then the debugging workflow might look something like this: + + +#. Ensure the CMS is *not* running. The debugger will start it on its own, and an already-running CMS will block the required ports. + However, if you have just run ``./tools/prune_database.sh``, be sure to run ``./tools/loadtestdata.sh`` before starting the debugger: + this is usually handled by ``./tools/run.sh``, but since the debugger does not use this script, it has no way of populating the database on its own! +#. Start the debugger using the configured key bind or the ``DapContinue`` command. + You will be presented with multiple options on which debugger configuration to start - select the one you have created (named "Launch Django DAP" in the example configuration above) and press enter. + + .. image:: images/debugging/debug-nvim-01-start.png + :alt: DAP configuration selection + +#. ``nvim-dap-ui`` will open and look something like this: + + .. image:: images/debugging/debug-nvim-02-overview.png + :alt: DAP UI overview + + * In the bottom right, you can see the usual console output you get when running the Integreat CMS. + * To the left of it, a number of controls are shown. From left to right these are used to: + + * Pause/resume the debugger; + * Step into: move the debugger pointer into a function call, or to the next statement; + * Step over: move the debugger pointer over a function call, to the next statement; + * Step out: finish the execution of the current function, then move the debugger pointer back into the calling function; + * Step backwards: usually not possible with python code, as the state of the program cannot be reverted; + * Restart the debugger; + * Stop the debugger; + * Disconnect the debugger, but leave the debugging process running. + + * The top box on the left is currently empty. It will later be filled with all in-scope variables and their current values. + * The second box on the left is also empty. It will list all currently set breakpoints. + * The third box on the left is also empty. It will list currently existing processes. + * The bottom box on the left allows you to write custom (python) expressions and to see their value. This is an extremely powerful feature. + Simply enter insert mode, type the python expression, and press enter. For more, see `this section `_ in the docs. + +#. Navigate to the ``get`` method and place your cursor at the first line of interest within it. Use the "toggle breakpoint" key bind or the ``DapToggleBreakpoint`` command to set a breakpoint. + The breakpoint appears in the second box on the left. + + .. image:: images/debugging/debug-nvim-03-breakpoint.png + :alt: Add a breakpoint + +#. In your browser, navigate to any page form view, for example: "Willkommen" in "Stadt Augsburg". + Note that your browser will appear to be stuck in the page list view, unable to finish loading the page form view - this is intentional! + The red breakpoint indicator has changed to a gray arrow, indicating the line the debugger is currently stopped at. + The information in the boxes on the left have changed to reflect the program state at this line. + You can navigate through the boxes with your usual key binds and extend the variable definitions to see their current values. + + .. image:: images/debugging/debug-nvim-04-start-debugging.png + :alt: First debugging step + +#. Pressing the (now no longer grayed-out) "step over" button, the cursor will move one line down. + At the same time, the variable ``region`` and its value is added to the "Locals" section in the top-left box, since it has been created in the execution step we just performed. + If you did install the ``nvim-dap-virtual-text`` plugin, variable values will also be placed next to that variable's definition in the code, where it will be updated when the value changes. + This can be a more intuitive visualization than using the variable box in the top-left corner. + + .. image:: images/debugging/debug-nvim-05-step-over.png + :alt: Step over demonstration + +#. Continue pressing "step over" or try one of the other stepping mechanisms until you have found the information you are looking for. + Note that the debugging process will never stop if you simply keep clicking "step over", even after you have reached and moved past the ``return`` statement at the bottom of the ``get`` method. +#. If you want to end the debugging process, click the "play" button to let the CMS run normally again. + Your debugging breakpoint will still exist, so repeating the attempt to load a page form view will put you right back into the debugging process. + + + +Nixvim +^^^^^^ + +If you are a user of the `nixvim `_ project (there's dozens of us! Dozens!!), include the following snippet somewhere inside your nix config. +This will result in a debugging config equivalent to the one described above for neovim. Usage is identical. + +.. code-block:: nix + + programs.nixvim.plugins.dap = { + enable = true; + extensions = { + dap-python.enable = true; + dap-ui.enable = true; + dap-virtual-text.enable = true; + }; + }; + + programs.nixvim.plugins.dap.configurations.python = [{ + name = "Launch Django DAP"; + type = "python"; + request = "launch"; + program = { __raw = /* lua */ '' vim.loop.cwd() .. "/.venv/bin/django-admin" ''; }; + args = [ "runserver" "--noreload" ]; + justMyCode = true; + django = true; + console = "integratedTerminal"; + env = { + "DJANGO_SETTINGS_MODULE" = "integreat_cms.core.settings"; + "INTEGREAT_CMS_DEBUG" = "true"; + "INTEGREAT_CMS_SECRET_KEY" = "dummy"; + }; + }]; + + programs.nixvim.keymaps = [ + { mode = "n"; key = "dB"; action = "lua require('dap').set_breakpoint(vim.fn.input('Breakpoint condition: '))"; options = { silent = true; desc = "Breakpoint Condition"; }; } + { mode = "n"; key = "db"; action = "DapToggleBreakpoint"; options = { silent = true; desc = "Toggle Breakpoint"; }; } + { mode = "n"; key = "dr"; action = "DapContinue"; options = { silent = true; desc = "Start or Continue"; }; } + { mode = "n"; key = "du"; action = "lua require('dapui').toggle()"; options = { silent = true; desc = "Dap UI"; }; } + ]; + +In case you are using the project's ``flake.nix`` to configure your environment, feel free to omit the ``env`` part of the DAP configuration. diff --git a/docs/src/images/debugging/debug-nvim-01-start.png b/docs/src/images/debugging/debug-nvim-01-start.png new file mode 100644 index 0000000000..8c158819e7 Binary files /dev/null and b/docs/src/images/debugging/debug-nvim-01-start.png differ diff --git a/docs/src/images/debugging/debug-nvim-02-overview.png b/docs/src/images/debugging/debug-nvim-02-overview.png new file mode 100644 index 0000000000..f73becad00 Binary files /dev/null and b/docs/src/images/debugging/debug-nvim-02-overview.png differ diff --git a/docs/src/images/debugging/debug-nvim-03-breakpoint.png b/docs/src/images/debugging/debug-nvim-03-breakpoint.png new file mode 100644 index 0000000000..8a35ae14c3 Binary files /dev/null and b/docs/src/images/debugging/debug-nvim-03-breakpoint.png differ diff --git a/docs/src/images/debugging/debug-nvim-04-start-debugging.png b/docs/src/images/debugging/debug-nvim-04-start-debugging.png new file mode 100644 index 0000000000..fd4a2cea65 Binary files /dev/null and b/docs/src/images/debugging/debug-nvim-04-start-debugging.png differ diff --git a/docs/src/images/debugging/debug-nvim-05-step-over.png b/docs/src/images/debugging/debug-nvim-05-step-over.png new file mode 100644 index 0000000000..f395e64380 Binary files /dev/null and b/docs/src/images/debugging/debug-nvim-05-step-over.png differ diff --git a/integreat_cms/cms/fixtures/test_data.json b/integreat_cms/cms/fixtures/test_data.json index 6e430e1b86..0c25c7d85d 100644 --- a/integreat_cms/cms/fixtures/test_data.json +++ b/integreat_cms/cms/fixtures/test_data.json @@ -956,7 +956,7 @@ "model": "cms.contact", "pk": 1, "fields": { - "title": "Integrationsbeauftragte", + "point_of_contact_for": "Integrationsbeauftragte", "name": "Martina Musterfrau", "location": 6, "email": "martina-musterfrau@example.com", @@ -971,7 +971,7 @@ "model": "cms.contact", "pk": 2, "fields": { - "title": "Integrationsberaterin", + "point_of_contact_for": "Integrationsberaterin", "name": "Melanie Musterfrau", "location": 6, "email": "melanie-musterfrau@example.com", @@ -986,7 +986,7 @@ "model": "cms.contact", "pk": 3, "fields": { - "title": "Integrationsbeauftragte", + "point_of_contact_for": "Integrationsbeauftragte", "name": "Mariana Musterfrau", "location": 6, "email": "mariana-musterfrau@example.com", @@ -997,6 +997,21 @@ "created_date": "2024-08-06T13:23:45.256Z" } }, + { + "model": "cms.contact", + "pk": 4, + "fields": { + "point_of_contact_for": "", + "name": "", + "location": 6, + "email": "generalcontactinformation@example.com", + "phone_number": "0123456789", + "website": "https://integreat-app.de/", + "archived": false, + "last_updated": "2024-08-06T13:23:45.256Z", + "created_date": "2024-08-06T13:23:45.256Z" + } + }, { "model": "cms.recurrencerule", "pk": 1, diff --git a/integreat_cms/cms/forms/contacts/contact_form.py b/integreat_cms/cms/forms/contacts/contact_form.py index 14b44fccc8..da1a14edb1 100644 --- a/integreat_cms/cms/forms/contacts/contact_form.py +++ b/integreat_cms/cms/forms/contacts/contact_form.py @@ -29,7 +29,7 @@ class Meta: model = Contact #: The fields of the model which should be handled by this form fields = [ - "title", + "point_of_contact_for", "name", "location", "email", diff --git a/integreat_cms/cms/forms/regions/region_form.py b/integreat_cms/cms/forms/regions/region_form.py index 8758ec7d05..144befc6c5 100644 --- a/integreat_cms/cms/forms/regions/region_form.py +++ b/integreat_cms/cms/forms/regions/region_form.py @@ -114,6 +114,15 @@ class RegionForm(CustomModelForm): ), ) + duplication_keep_translations = forms.BooleanField( + required=False, + label=_("Copy languages and content translations"), + help_text=_( + "Disable to skip copying of the language tree and all content translations." + ), + initial=True, + ) + duplication_pbo_behavior = forms.ChoiceField( choices=duplicate_pbo_behaviors.CHOICES, initial=duplicate_pbo_behaviors.ACTIVATE, @@ -271,6 +280,7 @@ def save(self, commit: bool = True) -> Region: if duplicate_region: source_region = self.cleaned_data["duplicated_region"] keep_status = self.cleaned_data["duplication_keep_status"] + keep_translations = self.cleaned_data["duplication_keep_translations"] # Determine offers to force activate or to skip when cloning pages required_offers = OfferTemplate.objects.filter(pages__region=source_region) @@ -292,7 +302,9 @@ def save(self, commit: bool = True) -> Region: # Duplicate language tree logger.info("Duplicating language tree of %r to %r", source_region, region) - duplicate_language_tree(source_region, region) + duplicate_language_tree( + source_region, region, only_root=not keep_translations + ) # Disable linkcheck listeners to prevent links to be created for outdated versions with disable_listeners(): # Duplicate pages @@ -302,6 +314,7 @@ def save(self, commit: bool = True) -> Region: region, keep_status=keep_status, offers_to_discard=offers_to_discard, + only_root=not keep_translations, ) # Duplicate Imprint if source_region.imprint: @@ -655,6 +668,7 @@ def duplicate_language_tree( source_parent: LanguageTreeNode | None = None, target_parent: LanguageTreeNode | None = None, logging_prefix: str = "", + only_root: bool = False, ) -> None: """ Function to duplicate the language tree of one region to another. @@ -669,6 +683,7 @@ def duplicate_language_tree( :param source_parent: The current parent node id of the recursion :param target_parent: The node of the target region which is the duplicate of the source parent node :param logging_prefix: recursion level to get a pretty log output + :param only_root: Set if only the root node should be copied, not its children """ logger.debug( "%s Duplicating child nodes", @@ -689,7 +704,7 @@ def duplicate_language_tree( " " if i == num_source_nodes - 1 else "│ " ) if target_parent: - # If the target parent already exist, we inherit its tree id for all its sub nodes + # If the target parent already exists, we inherit its tree id for all its sub nodes target_tree_id = target_parent.tree_id else: # If the language tree node is a root node, we need to assign a new tree id @@ -709,6 +724,11 @@ def duplicate_language_tree( target_node.tree_id = target_tree_id # Delete the primary key to force an insert target_node.pk = None + # Fix lft and rgt of the tree if the children of this node should not be cloned + # Otherwise, the tree structure will be inconsistent + if only_root: + target_node.lft = 1 + target_node.rgt = 2 # Check if the resulting node is valid target_node.full_clean() # Save the duplicated node @@ -718,7 +738,7 @@ def duplicate_language_tree( row_logging_prefix + ("└─" if source_node.is_leaf() else "├─"), target_node, ) - if not source_node.is_leaf(): + if not (source_node.is_leaf() or only_root): # Call the function recursively for all children of the current node duplicate_language_tree( source_region, @@ -729,7 +749,7 @@ def duplicate_language_tree( ) -# pylint: disable=too-many-locals, too-many-positional-arguments +# pylint: disable=too-many-locals,too-many-positional-arguments,too-many-arguments def duplicate_pages( source_region: Region, target_region: Region, @@ -738,6 +758,7 @@ def duplicate_pages( logging_prefix: str = "", keep_status: bool = False, offers_to_discard: QuerySet[OfferTemplate] | None = None, + only_root: bool = False, ) -> None: """ Function to duplicate all non-archived pages from one region to another @@ -754,6 +775,7 @@ def duplicate_pages( :param logging_prefix: Recursion level to get a pretty log output :param keep_status: Parameter to indicate whether the status of the cloned pages should be kept :param offers_to_discard: Offers which might be embedded in the source region, but not in the target region + :param only_root: Set if only the root node should be copied, not its children """ logger.debug( @@ -832,6 +854,7 @@ def duplicate_pages( row_logging_prefix, keep_status, offers_to_discard, + only_root, ) @@ -851,7 +874,11 @@ def duplicate_page_translations( logging_prefix + ("└─" if source_page.is_leaf() else "├─"), ) # Clone all page translations of the source page - source_page_translations = source_page.translations.all() + source_page_translations = source_page.translations.filter( + language__in=[ + node.language for node in target_page.region.language_tree_nodes.all() + ] + ) num_translations = len(source_page_translations) translation_row_logging_prefix = logging_prefix + ( " " if source_page.is_leaf() else "│ " @@ -877,12 +904,15 @@ def duplicate_page_translations( ) -def duplicate_imprint(source_region: Region, target_region: Region) -> None: +def duplicate_imprint( + source_region: Region, target_region: Region, only_root: bool = False +) -> None: """ Function to duplicate the imprint from one region to another. :param source_region: the source region from which the imprint should be duplicated :param target_region: the target region + :param only_root: Set if only the root node should be copied, not its children """ source_imprint = source_region.imprint target_imprint = deepcopy(source_imprint) @@ -894,6 +924,9 @@ def duplicate_imprint(source_region: Region, target_region: Region) -> None: target_imprint.save() + if only_root: + return + # Duplicate imprint translations by iterating to all existing ones source_page_translations = source_imprint.translations.all() diff --git a/integreat_cms/cms/migrations/0108_rename_contact_title_to_point_of_contact_for.py b/integreat_cms/cms/migrations/0108_rename_contact_title_to_point_of_contact_for.py new file mode 100644 index 0000000000..075c39bbb5 --- /dev/null +++ b/integreat_cms/cms/migrations/0108_rename_contact_title_to_point_of_contact_for.py @@ -0,0 +1,58 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + """ + Rename Contact.title to point_of_contact_for + """ + + dependencies = [ + ("cms", "0107_externalcalendar_author_fields"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="contact", + name="contact_singular_empty_title_per_location", + ), + migrations.RemoveConstraint( + model_name="contact", + name="contact_non_empty", + ), + migrations.RenameField( + model_name="contact", + old_name="title", + new_name="point_of_contact_for", + ), + migrations.AlterField( + model_name="contact", + name="point_of_contact_for", + field=models.CharField( + blank=True, max_length=200, verbose_name="point of contact for" + ), + ), + migrations.AddConstraint( + model_name="contact", + constraint=models.UniqueConstraint( + models.F("location"), + condition=models.Q(("point_of_contact_for", "")), + name="contact_singular_empty_point_of_contact_per_location", + violation_error_message="Only one contact per location can have an empty point of contact.", + ), + ), + migrations.AddConstraint( + model_name="contact", + constraint=models.CheckConstraint( + check=models.Q( + models.Q(("point_of_contact_for", ""), _negated=True), + models.Q(("name", ""), _negated=True), + models.Q(("email", ""), _negated=True), + models.Q(("phone_number", ""), _negated=True), + models.Q(("website", ""), _negated=True), + _connector="OR", + ), + name="contact_non_empty", + violation_error_message="One of the following fields must be filled: point of contact for, name, e-mail, phone number, website.", + ), + ), + ] diff --git a/integreat_cms/cms/migrations/0109_custom_truncating_char_field.py b/integreat_cms/cms/migrations/0109_custom_truncating_char_field.py new file mode 100644 index 0000000000..3e8a7dd215 --- /dev/null +++ b/integreat_cms/cms/migrations/0109_custom_truncating_char_field.py @@ -0,0 +1,51 @@ +from django.db import migrations + +import integreat_cms.cms.models.fields.truncating_char_field + + +class Migration(migrations.Migration): + """ + Migration file to change the field point_of_contact_for from a CharField to a TruncatingCharField + """ + + dependencies = [ + ("cms", "0108_rename_contact_title_to_point_of_contact_for"), + ] + + operations = [ + migrations.AlterField( + model_name="contact", + name="point_of_contact_for", + field=integreat_cms.cms.models.fields.truncating_char_field.TruncatingCharField( + blank=True, max_length=200, verbose_name="point of contact for" + ), + ), + migrations.AlterField( + model_name="eventtranslation", + name="title", + field=integreat_cms.cms.models.fields.truncating_char_field.TruncatingCharField( + max_length=1024, verbose_name="title" + ), + ), + migrations.AlterField( + model_name="imprintpagetranslation", + name="title", + field=integreat_cms.cms.models.fields.truncating_char_field.TruncatingCharField( + max_length=1024, verbose_name="title" + ), + ), + migrations.AlterField( + model_name="pagetranslation", + name="title", + field=integreat_cms.cms.models.fields.truncating_char_field.TruncatingCharField( + max_length=1024, verbose_name="title" + ), + ), + migrations.AlterField( + model_name="poitranslation", + name="title", + field=integreat_cms.cms.models.fields.truncating_char_field.TruncatingCharField( + max_length=1024, verbose_name="title" + ), + ), + ] diff --git a/integreat_cms/cms/models/abstract_content_translation.py b/integreat_cms/cms/models/abstract_content_translation.py index c73d5a7e60..2a80df8352 100644 --- a/integreat_cms/cms/models/abstract_content_translation.py +++ b/integreat_cms/cms/models/abstract_content_translation.py @@ -32,6 +32,7 @@ from ..utils.round_hix_score import round_hix_score from ..utils.translation_utils import gettext_many_lazy as __ from .abstract_base_model import AbstractBaseModel +from .fields.truncating_char_field import TruncatingCharField from .languages.language import Language logger = logging.getLogger(__name__) @@ -43,7 +44,7 @@ class AbstractContentTranslation(AbstractBaseModel): Data model representing a translation of some kind of content (e.g. pages or events) """ - title = models.CharField(max_length=1024, verbose_name=_("title")) + title = TruncatingCharField(max_length=1024, verbose_name=_("title")) slug = models.SlugField( max_length=1024, allow_unicode=True, @@ -632,7 +633,7 @@ def __str__(self) -> str: :return: A readable string representation of the content translation """ - return self.title + return str(self.title) def get_repr(self) -> str: """ diff --git a/integreat_cms/cms/models/contact/contact.py b/integreat_cms/cms/models/contact/contact.py index 680155840a..510cb93ab6 100644 --- a/integreat_cms/cms/models/contact/contact.py +++ b/integreat_cms/cms/models/contact/contact.py @@ -1,10 +1,12 @@ from django.db import models from django.db.models import Q +from django.db.utils import DataError from django.utils import timezone from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from ..abstract_base_model import AbstractBaseModel +from ..fields.truncating_char_field import TruncatingCharField from ..pois.poi import POI from ..regions.region import Region @@ -14,7 +16,9 @@ class Contact(AbstractBaseModel): Data model representing a contact """ - title = models.CharField(max_length=200, blank=True, verbose_name=_("title")) + point_of_contact_for = TruncatingCharField( + max_length=200, blank=True, verbose_name=_("point of contact for") + ) name = models.CharField(max_length=200, blank=True, verbose_name=_("name")) location = models.ForeignKey( POI, @@ -61,7 +65,31 @@ def __str__(self) -> str: :return: A readable string representation of the contact """ - return f"{self.title} {self.name}" + location_name = str(self.location) + additional_attribute = self.get_additional_attribute() + return " ".join(part for part in [location_name, additional_attribute] if part) + + def get_additional_attribute(self) -> str: + """ + This function determines which string is shown for the contact + """ + + if self.point_of_contact_for: + return _("with point of contact for: {}").format(self.point_of_contact_for) + + if self.name: + return _("with name: {}").format(self.name) + + if self.email: + return _("with email: {}").format(self.email) + + if self.phone_number: + return _("with phone number: {}").format(self.phone_number) + + if self.website: + return _("with website: {}").format(self.website) + + return "" def get_repr(self) -> str: """ @@ -70,7 +98,7 @@ def get_repr(self) -> str: :return: The canonical string representation of the contact """ - return f"" + return f"" def archive(self) -> None: """ @@ -90,9 +118,8 @@ def copy(self) -> None: """ Copies the contact """ - # In order to create a new object set pk to None self.pk = None - self.title = self.title + " " + _("(Copy)") + self.point_of_contact_for = self.point_of_contact_for + " " + _("(Copy)") self.save() class Meta: @@ -105,21 +132,21 @@ class Meta: constraints = [ models.UniqueConstraint( "location", - condition=Q(title=""), - name="contact_singular_empty_title_per_location", + condition=Q(point_of_contact_for=""), + name="contact_singular_empty_point_of_contact_per_location", violation_error_message=_( - "Only one contact per location can have an empty title." + "Only one contact per location can have an empty point of contact." ), ), models.CheckConstraint( - check=~Q(title="") + check=~Q(point_of_contact_for="") | ~Q(name="") | ~Q(email="") | ~Q(phone_number="") | ~Q(website=""), name="contact_non_empty", violation_error_message=_( - "One of the following fields must be filled: title, name, e-mail, phone number, website." + "One of the following fields must be filled: point of contact for, name, e-mail, phone number, website." ), ), ] diff --git a/integreat_cms/cms/models/events/event.py b/integreat_cms/cms/models/events/event.py index 90ede454b1..26dd35ae96 100644 --- a/integreat_cms/cms/models/events/event.py +++ b/integreat_cms/cms/models/events/event.py @@ -29,6 +29,12 @@ from ..users.user import User +class CouldNotBeCopied(Exception): + """ + Exception for events that can't be copied + """ + + class EventQuerySet(ContentQuerySet): """ Custom QuerySet to facilitate the filtering by date while taking recurring events into account. @@ -251,7 +257,12 @@ def copy(self, user: User) -> Event: :param user: The user who initiated this copy :return: A copy of this event + + :raises CouldNotBeCopied: When event can't be copied """ + if self.external_calendar: + raise CouldNotBeCopied + # save all translations on the original object, so that they can be copied later translations = list(self.translations.all()) diff --git a/integreat_cms/cms/models/fields/truncating_char_field.py b/integreat_cms/cms/models/fields/truncating_char_field.py new file mode 100644 index 0000000000..4fc7305ccc --- /dev/null +++ b/integreat_cms/cms/models/fields/truncating_char_field.py @@ -0,0 +1,16 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class TruncatingCharField(models.CharField): + """ + Custom model field for CharFields that need to be truncated + This is necessary in cases where we append a suffix and need to ensure to not exceed the limit and get a `DataError`. + """ + + def get_prep_value(self, value: str) -> str | None: + value = super().get_prep_value(value) + + if value and len(value) > self.max_length: + return value[: self.max_length - 3] + "..." + return value diff --git a/integreat_cms/cms/templates/_poi_query_result.html b/integreat_cms/cms/templates/_poi_query_result.html index 78077d4baa..1ba5b04d5f 100644 --- a/integreat_cms/cms/templates/_poi_query_result.html +++ b/integreat_cms/cms/templates/_poi_query_result.html @@ -13,15 +13,8 @@ {% for poi in poi_query_result %} {% endfor %} diff --git a/integreat_cms/cms/templates/ajax_poi_form/_poi_address_container.html b/integreat_cms/cms/templates/ajax_poi_form/_poi_address_container.html new file mode 100644 index 0000000000..9a08021ad6 --- /dev/null +++ b/integreat_cms/cms/templates/ajax_poi_form/_poi_address_container.html @@ -0,0 +1,58 @@ +{% load i18n %} +
+ + +
+ {% if poi %} + {{ poi.address }} +
+ {{ poi.postcode }} {{ poi.city }} +
+ {{ poi.country }} + {% endif %} +
+ + {% translate "Open on Google Maps" %} + + +
+

+ {% translate "E-mail address: " %} + + {% if poi.email %} + {{ poi.email }} + {% endif %} + + {% translate "Not provided" %} +

+

+ {% translate "Phone number: " %} + + {% if poi.phone_number %} + {{ poi.phone_number }} + {% endif %} + + {% translate "Not provided" %} +

+

+ {% translate "Website: " %} + + {% if poi.website %} + {{ poi.website }} + {% endif %} + + {% translate "Not provided" %} +

+
+
diff --git a/integreat_cms/cms/templates/ajax_poi_form/poi_box.html b/integreat_cms/cms/templates/ajax_poi_form/poi_box.html index c172044bf3..322d90c63d 100644 --- a/integreat_cms/cms/templates/ajax_poi_form/poi_box.html +++ b/integreat_cms/cms/templates/ajax_poi_form/poi_box.html @@ -19,7 +19,7 @@ {% endif %} {% if current_menu_item == "contacts" %} -
+
{{ help_text }}
{% endif %} @@ -55,64 +55,7 @@
{% include "_poi_query_result.html" %}
-
- - -
- {% if poi %} - {{ poi.address }} - {{ poi.postcode }} {{ poi.city }} - {{ poi.country }} - {% endif %} -
- - {% translate "Open on Google Maps" %} - - -
-

- {% translate "E-mail address: " %} - - {% if poi.email %} - {{ poi.email }} - {% endif %} - - {% translate "Not provided" %} -

-

- {% translate "Phone number: " %} - - {% if poi.phone_number %} - {{ poi.phone_number }} - {% endif %} - - {% translate "Not provided" %} -

-

- {% translate "Website: " %} - - {% if poi.website %} - {{ poi.website }} - {% endif %} - - {% translate "Not provided" %} -

-
-
+ {% include "ajax_poi_form/_poi_address_container.html" with disabled=form.has_not_location.value %}
-
- {% translate "Add missing data or change existing data. Select the data you want to import." %} +
+ {% translate "Enter the data you wish to store for the contact here." %}
-
diff --git a/integreat_cms/cms/templates/events/event_list_row.html b/integreat_cms/cms/templates/events/event_list_row.html index b93049803a..e1e8aacb90 100644 --- a/integreat_cms/cms/templates/events/event_list_row.html +++ b/integreat_cms/cms/templates/events/event_list_row.html @@ -141,13 +141,19 @@ {% endif %} {% has_perm 'cms.change_event' request.user as can_edit_event %} {% if can_edit_event %} -
- {% csrf_token %} - +
+ {% else %} + - + {% endif %}
@@ -130,7 +130,7 @@ {% for contact in poi_form.instance.contacts.all %} - {{ contact.title }} {{ contact.name }} + {{ contact.point_of_contact_for }} {{ contact.name }} {% endfor %} diff --git a/integreat_cms/cms/templates/pois/poi_form_sidebar/minor_edit_box.html b/integreat_cms/cms/templates/pois/poi_form_sidebar/minor_edit_box.html index 551b9f54e9..255ef680ab 100644 --- a/integreat_cms/cms/templates/pois/poi_form_sidebar/minor_edit_box.html +++ b/integreat_cms/cms/templates/pois/poi_form_sidebar/minor_edit_box.html @@ -44,6 +44,12 @@ {{ poi_translation_form.mt_translations_to_create.label }} {% render_field poi_translation_form.mt_translations_to_create %} +
+ + +
{% endif %} diff --git a/integreat_cms/cms/templates/regions/region_form.html b/integreat_cms/cms/templates/regions/region_form.html index e26eee7cb2..401e18296f 100644 --- a/integreat_cms/cms/templates/regions/region_form.html +++ b/integreat_cms/cms/templates/regions/region_form.html @@ -560,6 +560,13 @@

{{ form.duplication_keep_status.help_text }}
+ {% render_field form.duplication_keep_translations %} + +
+ {{ form.duplication_keep_translations.help_text }} +
diff --git a/integreat_cms/cms/templates/statistics/_statistics_legend.html b/integreat_cms/cms/templates/statistics/_statistics_legend.html new file mode 100644 index 0000000000..2fddce77b7 --- /dev/null +++ b/integreat_cms/cms/templates/statistics/_statistics_legend.html @@ -0,0 +1,24 @@ +{% load i18n %} +{% load static %} +
+

+ {% translate "Shown languages" %} +

+ {% for language in languages %} + {{ language }} + {% endfor %} +
+

+
+
+
+
+

+ {% translate "Accesses" %} +

+
+ {% for access in accesses %} + {{ access }} + {% endfor %} +
+
diff --git a/integreat_cms/cms/templates/statistics/_statistics_legend_item.html b/integreat_cms/cms/templates/statistics/_statistics_legend_item.html new file mode 100644 index 0000000000..71cb76e791 --- /dev/null +++ b/integreat_cms/cms/templates/statistics/_statistics_legend_item.html @@ -0,0 +1,21 @@ +{% load i18n %} +{% load static %} + +
+
+{% if language %} +
+ + {% if language.secondary_country_code %} + + {% endif %} +
+{% endif %} + diff --git a/integreat_cms/cms/templates/statistics/statistics_overview.html b/integreat_cms/cms/templates/statistics/statistics_overview.html index 2584469636..ed5b545901 100644 --- a/integreat_cms/cms/templates/statistics/statistics_overview.html +++ b/integreat_cms/cms/templates/statistics/statistics_overview.html @@ -10,7 +10,7 @@

-
+

{% translate "Total accesses" %} @@ -29,14 +29,20 @@

-

+

diff --git a/integreat_cms/cms/templatetags/poi_filters.py b/integreat_cms/cms/templatetags/poi_filters.py index 83e78d9352..65e1d2751c 100644 --- a/integreat_cms/cms/templatetags/poi_filters.py +++ b/integreat_cms/cms/templatetags/poi_filters.py @@ -7,6 +7,9 @@ from typing import TYPE_CHECKING from django import template +from django.template.loader import render_to_string +from django.utils.html import escape +from django.utils.safestring import SafeString if TYPE_CHECKING: from ..models import Language, POI @@ -31,3 +34,18 @@ def poi_translation_title(poi: POI, language: Language) -> str: poi_translation = all_poi_translations.first() return f"{poi_translation.title} ({poi_translation.language})" return "" + + +@register.simple_tag +def render_poi_address(poi: POI) -> SafeString: + """ + This tag returns encoded html for the poi address container of this poi + + :param poi: The requested point of interest + :return: An encoded html string + """ + return escape( + render_to_string( + "ajax_poi_form/_poi_address_container.html", {"poi": poi, "disabled": False} + ) + ) diff --git a/integreat_cms/cms/utils/external_calendar_utils.py b/integreat_cms/cms/utils/external_calendar_utils.py index 23b2907bf1..0957568144 100644 --- a/integreat_cms/cms/utils/external_calendar_utils.py +++ b/integreat_cms/cms/utils/external_calendar_utils.py @@ -52,6 +52,7 @@ def from_ical_event( :param logger: The logger to use :return: An instance of IcalEventData """ + # pylint: disable=too-many-locals event_id = event.decoded("UID").decode("utf-8") title = event.decoded("SUMMARY").decode("utf-8") content = clean_content( @@ -86,15 +87,25 @@ def from_ical_event( content[:32], ) + is_all_day = not ( + isinstance(start, datetime.datetime) and isinstance(end, datetime.datetime) + ) + if is_all_day: + start_date, start_time = start, None + end_date, end_time = end - datetime.timedelta(days=1), None + else: + start_date, start_time = start.date(), start.time() + end_date, end_time = end.date(), end.time() + return cls( event_id=event_id, title=title, content=content, - start_date=start.date() if isinstance(start, datetime.datetime) else start, - start_time=start.time() if isinstance(start, datetime.datetime) else None, - end_date=end.date() if isinstance(end, datetime.datetime) else end, - end_time=end.time() if isinstance(end, datetime.datetime) else None, - is_all_day=not isinstance(start, datetime.datetime), + start_date=start_date, + start_time=start_time, + end_date=end_date, + end_time=end_time, + is_all_day=is_all_day, external_calendar_id=external_calendar_id, categories=categories, ) diff --git a/integreat_cms/cms/views/contacts/contact_form_view.py b/integreat_cms/cms/views/contacts/contact_form_view.py index 78db05ebd8..357d6d030d 100644 --- a/integreat_cms/cms/views/contacts/contact_form_view.py +++ b/integreat_cms/cms/views/contacts/contact_form_view.py @@ -113,7 +113,7 @@ def post(self, request: HttpRequest, **kwargs: Any) -> HttpResponse: if not contact_instance: messages.success( request, - _('Contact "{}" was successfully created').format( + _('Contact for "{}" was successfully created').format( contact_form.instance ), ) @@ -122,7 +122,7 @@ def post(self, request: HttpRequest, **kwargs: Any) -> HttpResponse: else: messages.success( request, - _('Contact "{}" was successfully saved').format( + _('Contact for "{}" was successfully saved').format( contact_form.instance ), ) @@ -134,6 +134,14 @@ def post(self, request: HttpRequest, **kwargs: Any) -> HttpResponse: }, ) + help_text = ( + _("This location is used for the contact.") + if contact_instance + else _( + "Select a location to use for your contact or create a new location. Only published locations can be set." + ) + ) + return render( request, self.template_name, @@ -144,5 +152,6 @@ def post(self, request: HttpRequest, **kwargs: Any) -> HttpResponse: "referring_pages": None, "referring_locations": None, "referring_events": None, + "help_text": help_text, }, ) diff --git a/integreat_cms/cms/views/events/event_actions.py b/integreat_cms/cms/views/events/event_actions.py index e8d365d7af..f5c216aa42 100644 --- a/integreat_cms/cms/views/events/event_actions.py +++ b/integreat_cms/cms/views/events/event_actions.py @@ -17,6 +17,7 @@ from ...constants import status from ...decorators import permission_required from ...models import POITranslation, Region +from ...models.events.event import CouldNotBeCopied if TYPE_CHECKING: from django.http import HttpRequest, HttpResponse, HttpResponseRedirect @@ -72,7 +73,16 @@ def copy( region = request.region event = get_object_or_404(region.events, id=event_id) - event.copy(request.user) + try: + event.copy(request.user) + except CouldNotBeCopied: + messages.error( + request, + _("Event couldn't be copied because it's from an external calendar"), + ) + return redirect( + "events", **{"region_slug": region_slug, "language_slug": language_slug} + ) logger.debug("%r copied by %r", event, request.user) messages.success(request, _("Event was successfully copied")) diff --git a/integreat_cms/cms/views/events/event_context_mixin.py b/integreat_cms/cms/views/events/event_context_mixin.py index 3a4b375238..855e802841 100644 --- a/integreat_cms/cms/views/events/event_context_mixin.py +++ b/integreat_cms/cms/views/events/event_context_mixin.py @@ -51,6 +51,9 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: "help_text": _( "Create an event location or start typing the name of an existing location. Only published locations can be set as event venues." ), + "cannot_copy_title": _( + "An event from an external calendar can't be copied." + ), } ) return context diff --git a/integreat_cms/cms/views/linkcheck/linkcheck_list_view.py b/integreat_cms/cms/views/linkcheck/linkcheck_list_view.py index 9c6fc0a1db..02247609e1 100644 --- a/integreat_cms/cms/views/linkcheck/linkcheck_list_view.py +++ b/integreat_cms/cms/views/linkcheck/linkcheck_list_view.py @@ -195,6 +195,7 @@ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: selected_urls = get_urls( region_slug=region_slug, url_ids=request.POST.getlist("selected_ids[]"), + prefetch_region_links=True, ) if action == "ignore": diff --git a/integreat_cms/cms/views/pois/poi_form_ajax_view.py b/integreat_cms/cms/views/pois/poi_form_ajax_view.py index 1812fa1403..f8bd7ef62c 100644 --- a/integreat_cms/cms/views/pois/poi_form_ajax_view.py +++ b/integreat_cms/cms/views/pois/poi_form_ajax_view.py @@ -4,6 +4,7 @@ from django.http import JsonResponse from django.shortcuts import get_object_or_404, render +from django.template.loader import render_to_string from django.views.generic import TemplateView from ...forms import POIForm, POITranslationForm @@ -94,8 +95,7 @@ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: if not poi_form.is_valid() or not poi_translation_form.is_valid(): return JsonResponse( data={ - "poi_form": poi_form.get_error_messages(), - "poit_ranslation_form": poi_translation_form.get_error_messages(), + "success": False, } ) @@ -104,7 +104,10 @@ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: return JsonResponse( data={ - "success": "Successfully created location", - "id": poi_form.instance.id, + "success": True, + "poi_address_container": render_to_string( + "ajax_poi_form/_poi_address_container.html", + {"poi": poi_translation_form.instance.poi}, + ), } ) diff --git a/integreat_cms/cms/views/region_condition/region_condition_actions.py b/integreat_cms/cms/views/region_condition/region_condition_actions.py index 065bb08ada..43cc08f686 100644 --- a/integreat_cms/cms/views/region_condition/region_condition_actions.py +++ b/integreat_cms/cms/views/region_condition/region_condition_actions.py @@ -38,6 +38,8 @@ class RegionConditionResource(resources.ModelResource): num_low_hix_pages = fields.Field(column_name=_("Number of low hix pages")) + num_pages = fields.Field(column_name=_("Number of pages")) + num_pages_with_missing_or_outdated_translation = fields.Field( column_name=_( "Number of pages with at least one missing or outdated translation" @@ -72,6 +74,14 @@ def dehydrate_num_low_hix_pages(region: Region) -> int: """ return get_translation_under_hix_threshold(region).count() + @staticmethod + def dehydrate_num_pages(region: Region) -> int: + """ + :param region: The region + :return: The number of pages in this region + """ + return region.get_pages().count() + @staticmethod def dehydrate_num_pages_with_missing_or_outdated_translation(region: Region) -> int: """ diff --git a/integreat_cms/locale/de/LC_MESSAGES/django.po b/integreat_cms/locale/de/LC_MESSAGES/django.po index 24990687b6..9b5749b470 100644 --- a/integreat_cms/locale/de/LC_MESSAGES/django.po +++ b/integreat_cms/locale/de/LC_MESSAGES/django.po @@ -2251,6 +2251,17 @@ msgstr "" "Durch das Häkchen wird der Veröffentlichungsstatus der Seiten ebenfalls " "geklont und nicht auf Entwurf gesetzt." +#: cms/forms/regions/region_form.py +msgid "Copy languages and content translations" +msgstr "Sprachen und Übersetzungen kopieren" + +#: cms/forms/regions/region_form.py +msgid "" +"Disable to skip copying of the language tree and all content translations." +msgstr "" +"Abwählen, um das Kopieren des Sprachbaums und aller Inhaltsübersetzungen zu " +"überspringen." + #: cms/forms/regions/region_form.py msgid "Page based offers cloning behavior" msgstr "Klonverhalten für seitenbasierte Angebote" @@ -2554,7 +2565,7 @@ msgstr "Region" msgid "creation date" msgstr "Erstellungsdatum" -#: cms/models/abstract_content_translation.py cms/models/contact/contact.py +#: cms/models/abstract_content_translation.py #: cms/models/push_notifications/push_notification_translation.py msgid "title" msgstr "Titel" @@ -2707,6 +2718,10 @@ msgstr "Nutzer-Chat" msgid "user chats" msgstr "Nutzer-Chats" +#: cms/models/contact/contact.py +msgid "point of contact for" +msgstr "Ansprechperson für" + #: cms/models/contact/contact.py cms/models/media/directory.py #: cms/models/media/media_file.py cms/models/offers/offer_template.py #: cms/models/regions/region.py cms/models/users/organization.py @@ -2742,6 +2757,26 @@ msgstr "archiviert" msgid "Whether or not the location is read-only and hidden in the API." msgstr "Ob der Ort schreibgeschützt und in der API verborgen ist oder nicht." +#: cms/models/contact/contact.py +msgid "with point of contact for: {}" +msgstr "mit Ansprechperson für: {}" + +#: cms/models/contact/contact.py +msgid "with name: {}" +msgstr "mit Name: {}" + +#: cms/models/contact/contact.py +msgid "with email: {}" +msgstr "mit E-Mail-Adresse: {}" + +#: cms/models/contact/contact.py +msgid "with phone number: {}" +msgstr "mit Telefonnummer: {}" + +#: cms/models/contact/contact.py +msgid "with website: {}" +msgstr "mit Website: {}" + #: cms/models/contact/contact.py msgid "(Copy)" msgstr "(Kopie)" @@ -2755,16 +2790,18 @@ msgid "contacts" msgstr "Kontakte" #: cms/models/contact/contact.py -msgid "Only one contact per location can have an empty title." -msgstr "Nur ein Kontakt per Ort darf leeren Titel haben." +msgid "Only one contact per location can have an empty point of contact." +msgstr "" +"Pro Ort darf nur ein Kontakt ohne Angabe, wofür die Ansprechperson zuständig " +"ist, eingetragen werden." #: cms/models/contact/contact.py msgid "" -"One of the following fields must be filled: title, name, e-mail, phone " -"number, website." +"One of the following fields must be filled: point of contact for, name, e-" +"mail, phone number, website." msgstr "" -"Eines der folgenden Felder muss ausgefüllt werden: Title, Name, E-mail, " -"Telefonnummer, Website." +"Eines der folgenden Felder muss ausgefüllt werden: Ansprechperson für, Name, " +"E-Mail, Telefonnummer, Website." #: cms/models/events/event.py msgid "start" @@ -4812,51 +4849,51 @@ msgstr "Automatisch den Titel des verlinkten Inhalts als Linktext verwenden" msgid "Media Library..." msgstr "Medienbibliothek..." -#: cms/templates/ajax_poi_form/_poi_form_widget.html -#: cms/templates/events/event_form.html cms/templates/imprint/imprint_form.html -#: cms/templates/imprint/imprint_sbs.html cms/templates/pages/page_form.html -#: cms/templates/pages/page_sbs.html cms/templates/pois/poi_form.html -msgid "Publish" -msgstr "Veröffentlichen" - -#: cms/templates/ajax_poi_form/poi_box.html -msgid "Name of location" -msgstr "Name des Ortes" - -#: cms/templates/ajax_poi_form/poi_box.html -msgid "Remove location" -msgstr "Ort entfernen" - -#: cms/templates/ajax_poi_form/poi_box.html +#: cms/templates/ajax_poi_form/_poi_address_container.html #: cms/templates/pois/poi_form_sidebar/position_box.html msgid "Address" msgstr "Adresse" -#: cms/templates/ajax_poi_form/poi_box.html +#: cms/templates/ajax_poi_form/_poi_address_container.html msgid "Open on Google Maps" msgstr "Auf Google Maps öffnen" -#: cms/templates/ajax_poi_form/poi_box.html +#: cms/templates/ajax_poi_form/_poi_address_container.html #: cms/templates/pois/poi_form_sidebar/contact_box.html msgid "Contact details" msgstr "Kontaktdaten" -#: cms/templates/ajax_poi_form/poi_box.html +#: cms/templates/ajax_poi_form/_poi_address_container.html msgid "E-mail address: " msgstr "E-Mail-Adresse: " -#: cms/templates/ajax_poi_form/poi_box.html +#: cms/templates/ajax_poi_form/_poi_address_container.html msgid "Not provided" msgstr "Keine Angabe" -#: cms/templates/ajax_poi_form/poi_box.html +#: cms/templates/ajax_poi_form/_poi_address_container.html msgid "Phone number: " msgstr "Telefonnummer: " -#: cms/templates/ajax_poi_form/poi_box.html +#: cms/templates/ajax_poi_form/_poi_address_container.html msgid "Website: " msgstr "Website: " +#: cms/templates/ajax_poi_form/_poi_form_widget.html +#: cms/templates/events/event_form.html cms/templates/imprint/imprint_form.html +#: cms/templates/imprint/imprint_sbs.html cms/templates/pages/page_form.html +#: cms/templates/pages/page_sbs.html cms/templates/pois/poi_form.html +msgid "Publish" +msgstr "Veröffentlichen" + +#: cms/templates/ajax_poi_form/poi_box.html +msgid "Name of location" +msgstr "Name des Ortes" + +#: cms/templates/ajax_poi_form/poi_box.html +msgid "Remove location" +msgstr "Ort entfernen" + #: cms/templates/ajax_poi_form/poi_box.html msgid "The new location was successfully created." msgstr "Ein neuer Ort wurde erfolgreich erstellt." @@ -5218,11 +5255,9 @@ msgid "Manage contact data" msgstr "Kontaktdaten verwalten" #: cms/templates/contacts/contact_form.html -msgid "" -"Add missing data or change existing data. Select the data you want to import." +msgid "Enter the data you wish to store for the contact here." msgstr "" -"Ergänzen Sie fehlende Daten oder ändern bestehende Daten. Wählen Sie die " -"Daten aus, die übernommen werden sollen." +"Tragen Sie hier die Daten ein, die Sie für den Kontakt hinterlegen möchten." #: cms/templates/contacts/contact_form.html msgid "Contact information" @@ -5285,10 +5320,12 @@ msgid "Create contact" msgstr "Kontakt erstellen" #: cms/templates/contacts/contact_list.html -#: cms/templates/pages/_page_xliff_import_diff.html -#: cms/templates/push_notifications/push_notification_list.html -msgid "Title" -msgstr "Titel" +msgid "Name of related location" +msgstr "Name des verlinkten Ortes" + +#: cms/templates/contacts/contact_list.html +msgid "Point of contact for" +msgstr "Ansprechperson für" #: cms/templates/contacts/contact_list.html #: cms/templates/events/external_calendar_list.html @@ -5300,10 +5337,6 @@ msgstr "Titel" msgid "Name" msgstr "Name" -#: cms/templates/contacts/contact_list.html -msgid "Name of related location" -msgstr "Name des Veranstaltungsortes" - #: cms/templates/contacts/contact_list.html msgid "E-Mail" msgstr "E-Mail" @@ -5909,6 +5942,12 @@ msgstr "Diese Veranstaltung löschen" msgid "Date and time" msgstr "Datum und Uhrzeit" +#: cms/templates/events/event_form_sidebar/minor_edit_box.html +#: cms/templates/pages/page_form_sidebar/minor_edit_box.html +#: cms/templates/pois/poi_form_sidebar/minor_edit_box.html +msgid "Select all languages" +msgstr "Alle Sprachen auswählen" + #: cms/templates/events/event_list.html #: cms/templates/events/event_list_archived.html msgid "Archived events" @@ -7053,6 +7092,11 @@ msgstr "Quellcode" msgid "No changes detected." msgstr "Keine Änderungen vorgenommen." +#: cms/templates/pages/_page_xliff_import_diff.html +#: cms/templates/push_notifications/push_notification_list.html +msgid "Title" +msgstr "Titel" + #: cms/templates/pages/page_form.html #, python-format msgid "Edit page \"%(page_title)s\"" @@ -7189,10 +7233,6 @@ msgstr "" "Um automatische Übersetzungen nutzen zu können, muss der HIX-Wert mindestens " "%(minimum_hix)s betragen." -#: cms/templates/pages/page_form_sidebar/minor_edit_box.html -msgid "Select all languages" -msgstr "Alle Sprachen auswählen" - #: cms/templates/pages/page_form_sidebar/settings_box.html msgid "Settings of the page" msgstr "Einstellungen der Seite" @@ -8332,6 +8372,14 @@ msgstr "Trennen" msgid "Register authenticator app" msgstr "Authenticator-App registrieren" +#: cms/templates/statistics/_statistics_legend.html +msgid "Shown languages" +msgstr "Angezeigte Sprachen" + +#: cms/templates/statistics/_statistics_legend.html +msgid "Accesses" +msgstr "Zugriffe" + #: cms/templates/statistics/_statistics_widget.html msgid "Number of total accesses over the last 14 days." msgstr "Anzahl der Gesamtzugriffe der letzten 14 Tage." @@ -8346,10 +8394,8 @@ msgid "Total accesses" msgstr "Gesamtzugriffe" #: cms/templates/statistics/statistics_overview.html -msgid "Individual languages can be hidden by clicking on the labels." -msgstr "" -"Einzelne Sprachen können durch Anklicken der Beschriftungen ausgeblendet " -"werden." +msgid "Adjust shown data" +msgstr "Angezeigte Daten anpassen" #: cms/templates/statistics/statistics_overview.html msgid "Adjust time period" @@ -9064,13 +9110,13 @@ msgid "" "Select a location to use for your contact or create a new location. Only " "published locations can be set." msgstr "" -"Wählen Sie einen Ort aus, der für ihren Kontakt verwendet werden soll oder " +"Wählen Sie einen Ort aus, der für Ihren Kontakt verwendet werden soll oder " "erstellen Sie einen neuen Ort. Nur veröffentlichte Orte können verwendet " "werden." #: cms/views/contacts/contact_form_view.py -msgid "Contact \"{}\" was successfully created" -msgstr "Kontakt \"{}\" wurde erfolgreich erstellt" +msgid "Contact for \"{}\" was successfully created" +msgstr "Kontakt für \"{}\" wurde erfolgreich erstellt" #: cms/views/contacts/contact_form_view.py cms/views/content_version_view.py #: cms/views/events/event_form_view.py cms/views/pages/page_form_view.py @@ -9079,8 +9125,8 @@ msgid "No changes detected, but date refreshed" msgstr "Keine Änderungen vorgenommen, aber Datum aktualisiert" #: cms/views/contacts/contact_form_view.py -msgid "Contact \"{}\" was successfully saved" -msgstr "Kontakt \"{}\" wurde erfolgreich gespeichert" +msgid "Contact for \"{}\" was successfully saved" +msgstr "Kontakt für \"{}\" wurde erfolgreich gespeichert" #: cms/views/content_version_view.py msgid "Back to the form" @@ -9166,6 +9212,12 @@ msgstr "Bitte versuchen Sie, die Seite neu zu laden." msgid "Event was successfully archived" msgstr "Veranstaltung wurde erfolgreich archiviert" +#: cms/views/events/event_actions.py +msgid "Event couldn't be copied because it's from an external calendar" +msgstr "" +"Sie können diese Veranstaltung nicht kopieren, weil sie von einem externen " +"Kalender importiert worden ist." + #: cms/views/events/event_actions.py msgid "Event was successfully copied" msgstr "Veranstaltung wurde erfolgreich kopiert" @@ -9212,6 +9264,11 @@ msgstr "" "Veranstaltungsortes einzutippen. Nur veröffentlichte Orte können als " "Veranstaltungsorte verwendet werden." +#: cms/views/events/event_context_mixin.py +msgid "An event from an external calendar can't be copied." +msgstr "" +"Eine Veranstaltung aus einem externen Kalendar kann nicht kopiert werden." + #: cms/views/events/event_form_view.py msgid "You cannot edit this event because it is archived." msgstr "" @@ -10437,6 +10494,10 @@ msgstr "Anzahl kaputter Links" msgid "Number of low hix pages" msgstr "Anzahl Seiten mit niedrigem HIX-Wert" +#: cms/views/region_condition/region_condition_actions.py +msgid "Number of pages" +msgstr "Anzahl an Seiten" + #: cms/views/region_condition/region_condition_actions.py msgid "Number of pages with at least one missing or outdated translation" msgstr "" @@ -10793,6 +10854,10 @@ msgstr "Matomo API" msgid "Total Accesses" msgstr "Alle Zugriffe" +#: matomo_api/matomo_api_client.py +msgid "CW" +msgstr "KW" + #: matomo_api/matomo_api_client.py msgid "Offline Accesses" msgstr "Offline Zugriffe" @@ -10801,10 +10866,6 @@ msgstr "Offline Zugriffe" msgid "WebApp Accesses" msgstr "WebApp Zugriffe" -#: matomo_api/matomo_api_client.py -msgid "CW" -msgstr "KW" - #: nominatim_api/apps.py msgid "Nominatim API" msgstr "Nominatim API" @@ -10972,12 +11033,41 @@ msgstr "" "Diese Seite konnte nicht importiert werden, da sie zu einer anderen Region " "gehört ({})." +#~ msgid "{}{}" +#~ msgstr "{}{}" + +#~ msgid " " +#~ msgstr " " + +#, python-format +#~ msgid "%s %s" +#~ msgstr "%s %s" + +#, python-format +#~ msgid "%s with %s" +#~ msgstr "%s mit %s" + +#~ msgid "Individual languages can be hidden by clicking on the labels." +#~ msgstr "" +#~ "Einzelne Sprachen können durch Anklicken der Beschriftungen ausgeblendet " +#~ "werden." + +#~ msgid "Copy" +#~ msgstr "(Kopie)" + #~ msgid "Number of online accesses" #~ msgstr "Anzahl der Online-Zugriffe" #~ msgid "Created by" #~ msgstr "Erstellt von" +#~ msgid "" +#~ "Add missing data or change existing data. Select the data you want to " +#~ "import." +#~ msgstr "" +#~ "Ergänzen Sie fehlende Daten oder ändern bestehende Daten. Wählen Sie die " +#~ "Daten aus, die übernommen werden sollen." + #~ msgid "E-mail from location" #~ msgstr "E-mail des Ortes" diff --git a/integreat_cms/matomo_api/matomo_api_client.py b/integreat_cms/matomo_api/matomo_api_client.py index b6b240a1ca..e0af86421f 100644 --- a/integreat_cms/matomo_api/matomo_api_client.py +++ b/integreat_cms/matomo_api/matomo_api_client.py @@ -9,6 +9,7 @@ import aiohttp from django.conf import settings +from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ from ..cms.constants import language_color, matomo_periods @@ -345,6 +346,11 @@ def get_visits_per_language( # Get the separately created datasets for offline downloads offline_downloads = datasets.pop() + language_data, language_legends = self.get_language_data(languages, datasets) + access_data, access_legends = self.get_access_data( + total_visits, webapp_downloads, offline_downloads + ) + return { # Send original labels for usage in the CSV export (convert to list because type dict_keys is not JSON-serializable) "exportLabels": list(total_visits.keys()), @@ -352,47 +358,12 @@ def get_visits_per_language( "chartData": { # Make labels more readable "labels": self.simplify_date_labels(total_visits.keys(), period), - "datasets": - # The datasets for the visits by language - [ - { - "label": language.translated_name, - "backgroundColor": language.language_color, - "borderColor": language.language_color, - "data": list(dataset.values()), - } - # zip aggregates two lists into tuples, e.g. zip([1,2,3], [4,5,6])=[(1,4), (2,5), (3,6)] - # In this case, it matches the languages to their respective dataset (because the datasets are ordered) - for language, dataset in zip(languages, datasets) - ] - # The dataset for offline downloads - + [ - { - "label": _("Offline Accesses"), - "backgroundColor": language_color.OFFLINE_ACCESS, - "borderColor": language_color.OFFLINE_ACCESS, - "data": list(offline_downloads.values()), - } - ] - # The dataset for online/web app downloads - + [ - { - "label": _("WebApp Accesses"), - "backgroundColor": language_color.WEB_APP_ACCESS, - "borderColor": language_color.WEB_APP_ACCESS, - "data": list(webapp_downloads.values()), - } - ] - # The dataset for total visits - + [ - { - "label": _("Total Accesses"), - "backgroundColor": language_color.TOTAL_ACCESS, - "borderColor": language_color.TOTAL_ACCESS, - "data": list(total_visits.values()), - } - ], + "datasets": language_data + access_data, }, + "legend": render_to_string( + "statistics/_statistics_legend.html", + {"languages": language_legends, "accesses": access_legends}, + ), } @staticmethod @@ -461,3 +432,98 @@ def simplify_date_labels(date_labels: KeysView[str], period: str) -> list[Promis # This means the period is "year" (convert to list because type dict_keys is not JSON-serializable) simplified_date_labels = list(date_labels) return simplified_date_labels + + @staticmethod + def get_language_data( + languages: list[Language], datasets: list[dict] + ) -> tuple[list[dict], list[str]]: + """ + Structure the datasets for languages in a chart.js-compatible format, + returning it and the custom legend entries + + :param languages: The list of languages + :param datasets: The Matomo datasets + :return: The chart.js-datasets and custom legend entries + """ + data_entries = [] + legend_entries = [] + + for language, dataset in zip(languages, datasets): + data_entries.append( + { + "label": language.translated_name, + "backgroundColor": language.language_color, + "borderColor": language.language_color, + "data": list(dataset.values()), + } + ) + legend_entries.append( + render_to_string( + "statistics/_statistics_legend_item.html", + { + "name": language.translated_name, + "color": language.language_color, + "language": language, + }, + ) + ) + return data_entries, legend_entries + + @staticmethod + def get_access_data( + total_visits: dict, webapp_downloads: dict, offline_downloads: dict + ) -> tuple[list[dict], list[str]]: + """ + Structure the datasets for accesses in a chart.js-compatible format, + returning it and the custom legend entries + + :param total_visits: The total amount of visits + :param webapp_downloads: The amount of visits via the WebApp + :param offline_downloads: The amount of offline downloads + :return: The chart.js-datasets and custom legend entries + """ + data_entries = [ + { + "label": _("Offline Accesses"), + "backgroundColor": language_color.OFFLINE_ACCESS, + "borderColor": language_color.OFFLINE_ACCESS, + "data": list(offline_downloads.values()), + }, + { + "label": _("WebApp Accesses"), + "backgroundColor": language_color.WEB_APP_ACCESS, + "borderColor": language_color.WEB_APP_ACCESS, + "data": list(webapp_downloads.values()), + }, + { + "label": _("Total Accesses"), + "backgroundColor": language_color.TOTAL_ACCESS, + "borderColor": language_color.TOTAL_ACCESS, + "data": list(total_visits.values()), + }, + ] + + legend_entries = [ + render_to_string( + "statistics/_statistics_legend_item.html", + { + "name": _("Offline Accesses"), + "color": language_color.OFFLINE_ACCESS, + }, + ), + render_to_string( + "statistics/_statistics_legend_item.html", + { + "name": _("WebApp Accesses"), + "color": language_color.WEB_APP_ACCESS, + }, + ), + render_to_string( + "statistics/_statistics_legend_item.html", + { + "name": _("Total Accesses"), + "color": language_color.TOTAL_ACCESS, + }, + ), + ] + return data_entries, legend_entries diff --git a/integreat_cms/release_notes/current/unreleased/3133.yml b/integreat_cms/release_notes/current/unreleased/3133.yml new file mode 100644 index 0000000000..021c0c5941 --- /dev/null +++ b/integreat_cms/release_notes/current/unreleased/3133.yml @@ -0,0 +1,2 @@ +en: Move the legend for statistics to its own box +de: Verschiebe die Legende für Statistiken in eine eigene Box diff --git a/integreat_cms/release_notes/current/unreleased/3150.yml b/integreat_cms/release_notes/current/unreleased/3150.yml new file mode 100644 index 0000000000..74c0c8504a --- /dev/null +++ b/integreat_cms/release_notes/current/unreleased/3150.yml @@ -0,0 +1,2 @@ +en: Fix spacing in poi box +de: Behebe grafischen Fehler bei der Anzeige von Orten über das Eventformular diff --git a/integreat_cms/release_notes/current/unreleased/3183.yml b/integreat_cms/release_notes/current/unreleased/3183.yml new file mode 100644 index 0000000000..f7d7639044 --- /dev/null +++ b/integreat_cms/release_notes/current/unreleased/3183.yml @@ -0,0 +1,2 @@ +en: Fix error at bulk action link ignore and unignore +de: Behebe den Fehler bei der Mehrfachaktion der Links "Ignorieren" und "Nicht ignorieren" aufheben diff --git a/integreat_cms/release_notes/current/unreleased/3185.yml b/integreat_cms/release_notes/current/unreleased/3185.yml new file mode 100644 index 0000000000..c91f86c819 --- /dev/null +++ b/integreat_cms/release_notes/current/unreleased/3185.yml @@ -0,0 +1,2 @@ +en: Extend select all checkboxes to translation of events and pois +de: Weite das Kontrollkästchen "Alles auswählen" auf für Übersetzungen von Veranstaltungen und Orte aus diff --git a/integreat_cms/static/src/js/analytics/statistics-charts.ts b/integreat_cms/static/src/js/analytics/statistics-charts.ts index 9dadb6463d..5ee9a4b333 100644 --- a/integreat_cms/static/src/js/analytics/statistics-charts.ts +++ b/integreat_cms/static/src/js/analytics/statistics-charts.ts @@ -13,6 +13,7 @@ import { export type AjaxResponse = { exportLabels: Array; chartData: ChartData; + legend: string; }; // Register all components that are being used - the others will be excluded from the final webpack build @@ -34,7 +35,6 @@ const updateChart = async (): Promise => { const chartServerError = document.getElementById("chart-server-error"); const chartHeavyTrafficError = document.getElementById("chart-heavy-traffic-error"); const chartLoading = document.getElementById("chart-loading"); - const chartLabelHelpText = document.getElementById("chart-label-help-text"); // Hide error in case it was shown before chartNetworkError.classList.add("hidden"); @@ -86,8 +86,20 @@ const updateChart = async (): Promise => { chart.update(); // Save export labels exportLabels = data.exportLabels; - // Show help text - chartLabelHelpText?.classList.remove("hidden"); + + const legendDiv = document.getElementById("chart-legend-container"); + const legendDivInner = document.getElementById("chart-legend"); + if (legendDiv && legendDivInner) { + legendDivInner.innerHTML = data.legend; + legendDiv.classList.remove("hidden"); + } + const items = chart.options.plugins.legend.labels.generateLabels(chart); + items.forEach((item) => { + document.querySelector(`[data-chart-item="${item.text}"]`).addEventListener("change", () => { + chart.setDatasetVisibility(item.datasetIndex, !chart.isDatasetVisible(item.datasetIndex)); + chart.update(); + }); + }); } else if (response.status === HTTP_STATUS_BAD_REQUEST) { // Client error - invalid form parameters supplied const data = await response.json(); @@ -203,10 +215,7 @@ window.addEventListener("load", async () => { options: { plugins: { legend: { - labels: { - usePointStyle: true, - pointStyleWidth: 18, - }, + display: false, }, tooltip: { usePointStyle: true, diff --git a/integreat_cms/static/src/js/poi_box.ts b/integreat_cms/static/src/js/poi_box.ts index 2f321ccf81..f0fb1e7e4f 100644 --- a/integreat_cms/static/src/js/poi_box.ts +++ b/integreat_cms/static/src/js/poi_box.ts @@ -1,15 +1,7 @@ import { createIconsAt } from "./utils/create-icons"; import { getCsrfToken } from "./utils/csrf-token"; -const toggleContactInfo = (infoType: string, info: string) => { - document.getElementById(infoType).textContent = `${info}`; - const infoNotGivenMessage = document.getElementById(`no-${infoType}`); - if (info) { - infoNotGivenMessage.classList.add("hidden"); - } else { - infoNotGivenMessage.classList.remove("hidden"); - } -}; +type FormResponse = { success: boolean; poi_address_container: string }; const toggleContactFieldBox = (show: boolean) => { const contactFieldsBox = document.getElementById("contact_fields"); @@ -18,37 +10,15 @@ const toggleContactFieldBox = (show: boolean) => { contactUsageBox?.classList.toggle("hidden", !show); }; -const renderPoiData = ( - queryPlaceholder: string, - id: string, - address: string, - postcode: string, - city: string, - country: string, - email: string, - phoneNumber: string, - website: string -) => { - document.getElementById("poi-query-input").setAttribute("placeholder", queryPlaceholder); - document.getElementById("id_location")?.setAttribute("value", id); - const poiAddress = document.getElementById("poi-address"); - if (poiAddress) { - poiAddress.textContent = `${address}\n${postcode} ${city}\n${country}`; - } - if (document.getElementById("poi-contact-information")) { - toggleContactInfo("email", email); - toggleContactInfo("phone_number", phoneNumber); - toggleContactInfo("website", website); - } - - document - .getElementById("poi-google-maps-link") - ?.setAttribute( - "href", - `https://www.google.com/maps/search/?api=1&query=${address}, ${postcode} ${city}, ${country}` - ); +const hideSearchResults = () => { document.getElementById("poi-query-result").classList.add("hidden"); (document.getElementById("poi-query-input") as HTMLInputElement).value = ""; +}; + +const renderPoiData = (poiTitle: string, newPoiData: string) => { + document.getElementById("poi-address-container").outerHTML = newPoiData; + document.getElementById("poi-query-input").setAttribute("placeholder", poiTitle); + hideSearchResults(); toggleContactFieldBox(true); }; @@ -61,23 +31,12 @@ const hidePoiFormWidget = () => { const setPoi = ({ target }: Event) => { const option = (target as HTMLElement).closest(".option-existing-poi"); - renderPoiData( - option.getAttribute("data-poi-title"), - option.getAttribute("data-poi-id"), - option.getAttribute("data-poi-address"), - option.getAttribute("data-poi-postcode"), - option.getAttribute("data-poi-city"), - option.getAttribute("data-poi-country"), - option.getAttribute("data-poi-email"), - option.getAttribute("data-poi-phone_number"), - option.getAttribute("data-poi-website") - ); - // Show the address container + renderPoiData(option.getAttribute("data-poi-title"), option.getAttribute("data-poi-address")); document.getElementById("poi-address-container")?.classList.remove("hidden"); console.debug("Rendered POI data"); }; -const showMessage = (response: any) => { +const showMessage = (response: FormResponse) => { const timeoutDuration = 10000; if (response.success) { hidePoiFormWidget(); @@ -121,27 +80,14 @@ const showPoiFormWidget = async ({ target }: Event) => { }, body: formData, }); - // Handle messages - const messages = await response.json(); - console.debug(messages); - showMessage(messages); + // Handle responseData + const responseData: FormResponse = await response.json(); + console.debug(responseData); + showMessage(responseData); // If POI was created successful, show it as selected option - if (messages.success) { - console.debug(messages); - renderPoiData( - formData.get("title").toString(), - messages.id, - formData.get("address").toString(), - formData.get("postcode").toString(), - formData.get("city").toString(), - formData.get("country").toString(), - formData.get("email").toString(), - formData.get("phone_number").toString(), - formData.get("website").toString() - ); + if (responseData.success) { + renderPoiData(formData.get("title").toString(), responseData.poi_address_container); document.getElementById("poi-address-container")?.classList.remove("hidden"); - // Add the POI to the actual django form field - (document.getElementById("id_location") as HTMLInputElement).value = messages.id.toString(); } hidePoiFormWidget(); }); @@ -196,19 +142,16 @@ const queryPois = async (url: string, queryString: string, regionSlug: string, c }; const removePoi = () => { - renderPoiData( - document.getElementById("poi-query-input").getAttribute("data-default-placeholder"), - "-1", - "", - "", - "", - "", - "", - "", - "" - ); // Hide the address container document.getElementById("poi-address-container")?.classList.add("hidden"); + // Clear the search container + document + .getElementById("poi-query-input") + .setAttribute( + "placeholder", + document.getElementById("poi-query-input").getAttribute("data-default-placeholder") + ); + hideSearchResults(); // Clear the poi form hidePoiFormWidget(); toggleContactFieldBox(false); diff --git a/pyproject.toml b/pyproject.toml index ba163cbb9a..449c4cb901 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,7 @@ dev = [ "black", "build", "bumpver", + "debugpy", "djlint", "freezegun", "isort", @@ -249,6 +250,7 @@ dev-pinned = [ "coverage==7.6.1", "cssbeautifier==1.15.1", "dill==0.3.9", + "debugpy==1.8.5", "distlib==0.3.8", "djlint==1.35.2", "docutils==0.20.1", diff --git a/tests/cms/models/contacts/test_contacts.py b/tests/cms/models/contacts/test_contacts.py new file mode 100644 index 0000000000..ad096ffc1c --- /dev/null +++ b/tests/cms/models/contacts/test_contacts.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pytest_django.fixtures import SettingsWrapper + + +import pytest +from django.utils import translation + +from integreat_cms.cms.models import Contact + + +@pytest.mark.django_db +def test_contact_string( + load_test_data: None, + settings: SettingsWrapper, +) -> None: + """ + Test whether __str__ of contact model works as expected + :param load_test_data: The fixture providing the test data (see :meth:`~tests.conftest.load_test_data`) + """ + settings.LANGUAGE_CODE = "en" + + contact_1 = Contact.objects.filter(id=1).first() + assert ( + str(contact_1) + == "Draft location with point of contact for: Integrationsbeauftragte" + ) + + contact_4 = Contact.objects.filter(id=4).first() + assert ( + str(contact_4) + == "Draft location with email: generalcontactinformation@example.com" + ) diff --git a/tests/cms/test_duplicate_regions.py b/tests/cms/test_duplicate_regions.py index f67ac12055..9e7f59b366 100644 --- a/tests/cms/test_duplicate_regions.py +++ b/tests/cms/test_duplicate_regions.py @@ -53,13 +53,13 @@ def test_duplicate_regions( "longitude": 1, "latitude": 1, "duplicated_region": 1, + "duplication_keep_translations": True, "duplication_pbo_behavior": "activate_missing", "zammad_url": "https://zammad-test.example.com", "timezone": "Europe/Berlin", "mt_renewal_month": 6, }, ) - print(response.headers) assert response.status_code == 302 target_region = Region.objects.get(slug="cloned") @@ -98,12 +98,12 @@ def test_duplicate_regions( # Check if all cloned page translations exist and are identical source_page_translations = source_page.translations.all() # Limit target page translations to all that existed before the links might have been replaced - target_pages_translations = target_page.translations.filter( + target_page_translations = target_page.translations.filter( last_updated__lt=before_cloning ) - assert len(source_page_translations) == len(target_pages_translations) + assert len(source_page_translations) == len(target_page_translations) for source_page_translation, target_page_translation in zip( - source_page_translations, target_pages_translations + source_page_translations, target_page_translations ): source_page_translation_dict = model_to_dict( source_page_translation, @@ -178,3 +178,91 @@ def test_duplicate_regions( combinations = model.objects.values_list("tree_id", "region") tree_ids = [tree_id for tree_id, region in set(combinations)] assert len(tree_ids) == len(set(tree_ids)) + + +# pylint: disable=too-many-locals +@pytest.mark.order("last") +@pytest.mark.django_db(transaction=True, serialized_rollback=True) +def test_duplicate_regions_no_translations( + load_test_data_transactional: None, admin_client: Client +) -> None: + """ + Test whether duplicating regions works as expected when disabling the duplication of translations + + :param load_test_data_transactional: The fixture providing the test data (see :meth:`~tests.conftest.load_test_data_transactional`) + :param admin_client: The fixture providing the logged in admin (see :fixture:`admin_client`) + """ + source_region = Region.objects.get(id=1) + target_region_slug = "cloned" + assert not Region.objects.filter( + slug=target_region_slug + ).exists(), "The target region should not exist before cloning" + + before_cloning = timezone.now() + url = reverse("new_region") + response = admin_client.post( + url, + data={ + "administrative_division": "CITY", + "name": "cloned", + "admin_mail": "cloned@example.com", + "postal_code": "11111", + "status": "ACTIVE", + "longitude": 1, + "latitude": 1, + "duplicated_region": 1, + "duplication_keep_translations": False, + "duplication_pbo_behavior": "activate_missing", + "zammad_url": "https://zammad-test.example.com", + "timezone": "Europe/Berlin", + "mt_renewal_month": 6, + }, + ) + assert response.status_code == 302 + + target_region = Region.objects.get(slug="cloned") + + source_language_root = source_region.language_tree_root.language + target_languages = target_region.languages + assert len(target_languages) == 1 + assert source_language_root in target_languages + + # Check if all cloned pages exist and are identical + source_pages = source_region.non_archived_pages + target_pages = target_region.pages.all() + assert len(source_pages) == len(target_pages) + for source_page, target_page in zip(source_pages, target_pages): + source_page_dict = model_to_dict( + source_page, + exclude=[ + "id", + "tree_id", + "region", + "parent", + "api_token", + "authors", + "editors", + ], + ) + target_page_dict = model_to_dict( + target_page, + exclude=[ + "id", + "tree_id", + "region", + "parent", + "api_token", + "authors", + "editors", + ], + ) + assert source_page_dict == target_page_dict + + source_page_translations_filtered = source_page.translations.filter( + language=source_language_root + ) + target_page_translations = target_page.translations.filter( + last_updated__lt=before_cloning + ) + assert len(source_page_translations_filtered) == len(target_page_translations) + assert target_page_translations[0].language == source_language_root diff --git a/tests/cms/views/contacts/test_contact_actions.py b/tests/cms/views/contacts/test_contact_actions.py index 2cbb3e91e7..2b8810a31e 100644 --- a/tests/cms/views/contacts/test_contact_actions.py +++ b/tests/cms/views/contacts/test_contact_actions.py @@ -9,12 +9,12 @@ def test_copying_contact_works( load_test_data: None, login_role_user: tuple[Client, str], ) -> None: - assert Contact.objects.all().count() == 3 + assert Contact.objects.all().count() == 4 contact = Contact.objects.get(id=1) contact.copy() - assert Contact.objects.all().count() == 4 + assert Contact.objects.all().count() == 5 @pytest.mark.django_db @@ -22,12 +22,12 @@ def test_deleting_contact_works( load_test_data: None, login_role_user: tuple[Client, str], ) -> None: - assert Contact.objects.all().count() == 3 + assert Contact.objects.all().count() == 4 contact = Contact.objects.get(id=1) contact.delete() - assert Contact.objects.all().count() == 2 + assert Contact.objects.all().count() == 3 @pytest.mark.django_db @@ -35,13 +35,13 @@ def test_archiving_contact_works( load_test_data: None, login_role_user: tuple[Client, str], ) -> None: - assert Contact.objects.all().count() == 3 + assert Contact.objects.all().count() == 4 contact = Contact.objects.get(id=1) assert contact.archived is False contact.archive() - assert Contact.objects.all().count() == 3 + assert Contact.objects.all().count() == 4 assert contact.archived is True @@ -50,11 +50,11 @@ def test_restoring_contact_works( load_test_data: None, login_role_user: tuple[Client, str], ) -> None: - assert Contact.objects.all().count() == 3 + assert Contact.objects.all().count() == 4 contact = Contact.objects.get(id=2) assert contact.archived is True contact.restore() - assert Contact.objects.all().count() == 3 + assert Contact.objects.all().count() == 4 assert contact.archived is False diff --git a/tests/cms/views/contacts/test_contact_form.py b/tests/cms/views/contacts/test_contact_form.py new file mode 100644 index 0000000000..2150228ac3 --- /dev/null +++ b/tests/cms/views/contacts/test_contact_form.py @@ -0,0 +1,290 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from _pytest.logging import LogCaptureFixture + from django.test.client import Client + from pytest_django.fixtures import SettingsWrapper + +import pytest +from django.test.client import Client +from django.urls import reverse + +from integreat_cms.cms.models import Contact, Region +from tests.conftest import ANONYMOUS, HIGH_PRIV_STAFF_ROLES +from tests.utils import assert_message_in_log + +# Use the region Augsburg, as it has some contacts in the test data +REGION_SLUG = "augsburg" +# Use the location with id=6, as it is used by the contacts of Augsburg and has already a primary contact. +POI_ID = 6 + + +@pytest.mark.django_db +def test_create_a_new_contact( + load_test_data: None, + login_role_user: tuple[Client, str], + settings: SettingsWrapper, + caplog: LogCaptureFixture, +) -> None: + """ + Test that a new contact is created successfully when all the necessary input are given. + """ + client, role = login_role_user + + # Set the language setting to English so assertion does not fail because of corresponding German sentence appearing instead the english one. + settings.LANGUAGE_CODE = "en" + + new_contact = reverse( + "new_contact", + kwargs={ + "region_slug": REGION_SLUG, + }, + ) + response = client.post( + new_contact, + data={ + "location": POI_ID, + "point_of_contact_for": "Title", + "name": "Name", + "email": "mail@mail.integreat", + "phone_number": "0123456789", + "website": "https://integreat-app.de/", + }, + ) + + if role in HIGH_PRIV_STAFF_ROLES: + assert_message_in_log( + 'SUCCESS Contact for "Draft location with point of contact for: Title" was successfully created', + caplog, + ) + edit_url = response.headers.get("location") + response = client.get(edit_url) + assert ( + "Contact for "Draft location with point of contact for: Title" was successfully created" + in response.content.decode("utf-8") + ) + + elif role == ANONYMOUS: + assert response.status_code == 302 + assert ( + response.headers.get("location") + == f"{settings.LOGIN_URL}?next={new_contact}" + ) + else: + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_edit_a_contact( + load_test_data: None, + login_role_user: tuple[Client, str], + settings: SettingsWrapper, + caplog: LogCaptureFixture, +) -> None: + """ + Test that a contact is changed successfully when all the necessary input are given. + """ + client, role = login_role_user + + # Set the language setting to English so assertion does not fail because of corresponding German sentence appearing instead the english one. + settings.LANGUAGE_CODE = "en" + + region = Region.objects.filter(slug=REGION_SLUG).first() + contact_id = Contact.objects.filter(location__region=region).first().id + + edit_contact = reverse( + "edit_contact", + kwargs={ + "region_slug": REGION_SLUG, + "contact_id": contact_id, + }, + ) + response = client.post( + edit_contact, + data={ + "location": POI_ID, + "point_of_contact_for": "Title Updated", + "name": "New Name", + "email": "mail@mail.integreat", + "phone_number": "0123456789", + "website": "https://integreat-app.de/", + }, + ) + + if role in HIGH_PRIV_STAFF_ROLES: + assert_message_in_log( + 'SUCCESS Contact for "Draft location with point of contact for: Title Updated" was successfully saved', + caplog, + ) + edit_url = response.headers.get("location") + response = client.get(edit_url) + assert ( + "Contact for "Draft location with point of contact for: Title Updated" was successfully saved" + in response.content.decode("utf-8") + ) + + elif role == ANONYMOUS: + assert response.status_code == 302 + assert ( + response.headers.get("location") + == f"{settings.LOGIN_URL}?next={edit_contact}" + ) + else: + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_no_contact_without_poi( + load_test_data: None, + login_role_user: tuple[Client, str], + settings: SettingsWrapper, + caplog: LogCaptureFixture, +) -> None: + """ + Test that a new contact cannot be created without any POI selected. + """ + client, role = login_role_user + + # Set the language setting to English so assertion does not fail because of corresponding German sentence appearing instead the english one. + settings.LANGUAGE_CODE = "en" + + new_contact = reverse( + "new_contact", + kwargs={ + "region_slug": REGION_SLUG, + }, + ) + response = client.post( + new_contact, + data={ + "point_of_contact_for": "Title", + "name": "Name", + "email": "mail@mail.integreat", + "phone_number": "0123456789", + "website": "https://integreat-app.de/", + }, + ) + + if role in HIGH_PRIV_STAFF_ROLES: + assert_message_in_log( + "ERROR Location: This field is required.", + caplog, + ) + assert "Location: This field is required." in response.content.decode("utf-8") + + elif role == ANONYMOUS: + assert response.status_code == 302 + assert ( + response.headers.get("location") + == f"{settings.LOGIN_URL}?next={new_contact}" + ) + else: + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_at_least_one_field_filled( + load_test_data: None, + login_role_user: tuple[Client, str], + settings: SettingsWrapper, + caplog: LogCaptureFixture, +) -> None: + """ + Test that a new contact cannot be created when all the fields are left empty. + """ + client, role = login_role_user + + # Set the language setting to English so assertion does not fail because of corresponding German sentence appearing instead the english one. + settings.LANGUAGE_CODE = "en" + + new_contact = reverse( + "new_contact", + kwargs={ + "region_slug": REGION_SLUG, + }, + ) + response = client.post( + new_contact, + data={ + "location": POI_ID, + "point_of_contact_for": "", + "name": "", + "email": "", + "phone_number": "", + "website": "", + }, + ) + + if role in HIGH_PRIV_STAFF_ROLES: + assert_message_in_log( + "ERROR One of the following fields must be filled: point of contact for, name, e-mail, phone number, website.", + caplog, + ) + assert ( + "One of the following fields must be filled: point of contact for, name, e-mail, phone number, website." + in response.content.decode("utf-8") + ) + + elif role == ANONYMOUS: + assert response.status_code == 302 + assert ( + response.headers.get("location") + == f"{settings.LOGIN_URL}?next={new_contact}" + ) + else: + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_one_primary_contact_per_poi( + load_test_data: None, + login_role_user: tuple[Client, str], + settings: SettingsWrapper, + caplog: LogCaptureFixture, +) -> None: + """ + Test that for each POI no second contact without title and name can be created. + """ + client, role = login_role_user + + # Set the language setting to English so assertion does not fail because of corresponding German sentence appearing instead the english one. + settings.LANGUAGE_CODE = "en" + + new_contact = reverse( + "new_contact", + kwargs={ + "region_slug": REGION_SLUG, + }, + ) + response = client.post( + new_contact, + data={ + "location": POI_ID, + "point_of_contact_for": "", + "name": "", + "email": "mail@mail.integreat", + "phone_number": "0123456789", + "website": "https://integreat-app.de/", + }, + ) + + if role in HIGH_PRIV_STAFF_ROLES: + assert_message_in_log( + "ERROR Only one contact per location can have an empty point of contact.", + caplog, + ) + assert ( + "Only one contact per location can have an empty point of contact." + in response.content.decode("utf-8") + ) + + elif role == ANONYMOUS: + assert response.status_code == 302 + assert ( + response.headers.get("location") + == f"{settings.LOGIN_URL}?next={new_contact}" + ) + else: + assert response.status_code == 403 diff --git a/tests/cms/views/events/events.py b/tests/cms/views/events/events.py new file mode 100644 index 0000000000..7b7a4eff76 --- /dev/null +++ b/tests/cms/views/events/events.py @@ -0,0 +1,36 @@ +from datetime import timedelta + +import pytest +from django.test.client import Client +from django.utils import timezone + +from integreat_cms.cms.models import Event, ExternalCalendar, Region +from integreat_cms.cms.models.events.event import CouldNotBeCopied + + +@pytest.mark.django_db +def test_copying_imported_event_is_unsucessful( + load_test_data: None, + login_role_user: tuple[Client, str], +) -> None: + _, role = login_role_user + region = Region.objects.create(name="Testregion") + + external_calendar = ExternalCalendar.objects.create( + name="Test external calendar", + region=region, + ) + + new_event = Event.objects.create( + start=timezone.now(), + end=timezone.now() + timedelta(days=1), + region=region, + external_calendar=external_calendar, + ) + + with pytest.raises(CouldNotBeCopied) as e: + new_event.copy(role) + assert ( + str(e.value) + == "Event couldn't be copied because it's from a external calendar" + )