.. _code style: =========== Code Style =========== Code style in this project is based off general style guide defined in `SOP-008 `_ General Python style guidelines ================================ Quote style As a general rule, double quotes (``"``) should be preferred for user-facing strings and messages such as display strings, log entries, error messages, etc., while single quotes (``'``) should be used for other strings where possible. .. code-block:: :caption: Example use of quotation marks message = "You're using an outdated browser. Please upgrade." code = 'browser-version' get_logger().debug("User logged in") Docstrings Classes, functions and methods should be documented using docstrings where practical, in particular utility functions and classes. Docstring should document the purpose, accepted arguments, return value and any relevant exceptions that may be raised by the documented code. A docstring should go beyond what can already be inferred by function name, signature or type annotation. A complex function's docstring can be broken into multiple paragraphs. .. seealso:: `Documenting Python objects `_ Type annotations This project uses type annotations since migration to Python 3 in :Task:`22300`. All newly implemented methods, functions, properties, etc should be properly type-annotated where possible. Methods returning instance of its class should use :class:`~typing.Self` as the return type. For example: :code:`def for_user(self, user: User) -> Self:` Generic types should be specified where known (e.g. :code:`list[str]`, :code:`dict[str, tuple[int]]`) File paths Use :class:`pathlib.Path` to represent file paths (rather than string representation). .. seealso:: `Python pathlib documentation `_ Project requirements Use :file:`requirements.txt` to list packages required by the project. Exact package version should be specified. To pin down version of an indirect dependency, use :file:`constraints.txt`. Requirements only used in development, or CI pipelines should be added to a separate :file:`requirements-dev.txt`. Static analysis tools and flake8 extensions can be added to a separate :file:`requirements-lint.txt`. Binary operations Use binary operators instead of class methods when operating on Python data structures for better readability. .. code-block:: a: set[int] = {1, 2, 3, 4, 5} b: set[int] = {4, 5, 6, 7, 8} union = a | b intersection = a & b diff = a - b # When combining operators, the improved readability becomes more obvious union_intersection_diff = (a | b) - (a & b) Calling ``super()`` implementation All calls to :code:`super()` should be done without any arguments to ensure the full class hierarchy is called even if superclass structure changes. This call should be the first one in the method body. .. code-block:: :caption: Example of a ``super()`` call to ``__init__()``. def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) Django-specific style rules ============================ Django API and version This project should use the latest available Long Term Support (LTS) version of django, with latest security patches. .. seealso:: `Django release notes `_ Class Based Views The project uses Django's `Class Based Views `_ (CBV). CBVs should be preferred over the legacy function views. There's a number of `built-in base view classes `_ provided by django (e.g. :class:`~django.views.generic.list.ListView`, :class:`~django.views.generic.edit.FormView`, :class:`~django.views.generic.edit.UpdateView`, etc) as well as mix-ins (e.g :class:`~django.contrib.auth.mixins.PermissionRequiredMixin`, :class:`~django.contrib.auth.mixins.LoginRequiredMixin`) that reduce the boilerplate code for handling forms, lists, etc. This project also provides a number of useful mix-ins for common use cases: :class:`~megforms.views.base.AuditorInstitutionMixin`, :class:`~megforms.views.base.AuditFormMixin`, :class:`~megforms.views.base.FilterFormMixin`. .. seealso:: | `Generic display views `_ | `Generic editing views `_ .. _code-style-i18n: Internationalization & Localization This product is used internationally and supports multiple languages and a wide range of timezones. .. seealso:: More about translation and internationalization in :ref:`translations` Translation strings should contain placeholders for any variables used within the sentence. This gives the translators the necessary context information, and control over how the value is positioned within the sentence. .. code-block:: html+django :caption: Example of correct variable interpolation within a django template :emphasize-lines: 2 Correct translation: {% blocktrans %}Hello {{ name }},{% endblocktrans %} Incorrect: {% trans "Hello" %} {{ name }}, Dates and times are stored in the database in UTC timezone. When displaying to the user, ensure that correct `timezone `_ is used. While locale is automatically set for authenticated users using middleware, celery jobs and other code executed outside of a request stack should set appropriate locale and timezone (e.g. by using :class:`~megforms.utils.LocaleWrapper` and :class:`~megforms.utils.TimezoneWrapper` context managers). .. code-block:: :caption: Example how to correctly set locales within a celery task :emphasize-lines: 4 @app.task(**CELERY_TASK_LOW_PRIORITY) def celery_job(institution_id: int): institution: Institution = Institution.objects.get(pk=institution_id) with LocaleWrapper(institution.language), TimezoneWrapper(institution.timezone_name): ... # This code will run correct in institution's timezone and language Performance Use available tools such as :meth:`~django.db.models.query.QuerySet.select_related`, :meth:`~django.db.models.query.QuerySet.prefetch_related`, :meth:`~django.db.models.query.QuerySet.annotate`, :class:`~django.db.models.expressions.Subquery` as applicable to prefetch any data that will be needed and avoid additional round trips to the database. Any long running code should be delegated to Celery tasks (e.g. batch processing, generating exports, parsing import data). Use appropriate data structures for their intended purpose (i.e. ``list``, ``tuple``, ``set``, ``dict``). Config JSON fields `JSON fields `_ are commonly used in the project as a store for arbitrary configuration options to avoid creating multiple dedicated model fields. To enforce validation of the input data and type safety, a `TypedDict `_ should be created and maintained that will explicitly describe the correct schema for a the config field. As one of the benefits of using ``JSONField`` is to avoid migration, documenting config fields in model's help text should be avoided, as updating ``help_text`` in django requires a migration. Instead, help text can be added to the field on form level. * The ``TypedDict`` should list all fields, their expected type, and have ``total=True``. * The model's ``JSONField`` should be type-annotated * The field should use :class:`~megforms.validators.TypedDictValidator` to enforce that user-entered data conforms to the expected structure * Any validation that cannot be expressed using ``TypedDict`` should be implemented separately (e.g. on model's clean class) * Nested ``TypedDict`` should be used when config needs to be broken into multiple sections of related options - this will also help enforce that all options within that section are set when the section is present in teh given config dict. .. code-block:: python :caption: Example setup of a json config field and its schema defined as a typed dict class PayloadDict(TypedDict, total=True): name: str address: list[str] class RequestConfig(TypedDict, total=True): url: str headers: dict[str, str] method: Literal['GET', 'POST', 'PUT'] verify: bool timeout: NotRequired[float | int] payload: PayloadDict certificates: list[str] class Request(BaseModel): config: RequestConfig = models.JSONField(verbose_name=_('configuration'), validators=[TypedDictValidator(RequestConfig)]) JavaScript rules ================== Supported version JS can leverage features declared on the :file:`.jshintrc` file in the project (e.g. ES9) Implementation JavaScript logic should be implemented in individual js files, and served from ``/static/`` folder in the relevant app. HTML files should contain minimal amount of js code in the :code:` .. code-block:: html+django :caption: More complex data structures can be securely passed to a js function by rendering them into a data tag first {{ message|json_script:"popup-data" }} Project-specific rules ======================== Unpublished objects :class:`~megforms.base_model.BaseModel` provides a :code:`published` field used to mark objects as deleted. Use the provided :meth:`~megforms.base_model.BaseQuerySet.published` queryset method to exclude those objects from querysets. Ringfencing Before displaying data to the user, ensure that the underlying querysets are properly :ref:`ringfenced ` by using :meth:`~megforms.base_model.BaseQuerySet.for_user`, :meth:`~megforms.base_model.BaseQuerySet.for_institutions` and other queryset methods as appropriate. Project settings Use django's :file:`settings.py` to keep project configuration. In addition, most settings will also benefit from being configurable by environment variable. This allows the value to be changed and tweaked without re-building and re-deploying the project. Use :func:`~getenv.env.env` to read the environment variable value. .. code-block:: :caption: Settings example # Setting with a default value SAMPLE_RATE: float = env('SAMPLE_RATE', 1.0) Models New models should inherit :class:`~megforms.base_model.BaseModel` as the base model. If implementing a custom queryset class, the queryset should inherit from :class:`~megforms.base_model.BaseQuerySet` Non-abstract models must implement a :class:`Meta` class with a translatable ``verbose_name``/``plural``. All models should implement :meth:`__str__`. The implementation should not query related models if possible (e.g. by only using the model's ``name`` or ``title`` field if one exist). Django admin Django admin pages should: * use decorators to register the admin class, and to annotate any any actions or display names * Extend :class:`~megforms.admin.BaseAdmin` as the base class .. code-block:: python :caption: Example of a well-formatted django admin class, using decorators @admin.register(ExampleModel) class ExampleAdmin(BaseAdmin): readonly_fields = 'example_computed_field', actions = [ 'example_action', ] @admin.action(description=_('Example action'), permissions=('change',)) def example_action(self, request: HttpRequest, qs: QuerySet[ExampleModel]): ... @admin.display(description=_('Label for the computed field')) def example_computed_field(self, obj: ExampleModel) -> str: ... .. seealso:: `django.contrib.admin.decorators `_ Capitalization User-facing strings in templates, field labels, etc, should use "Sentence case", or "lower case". If other style is required by :ref:`style-guide`, it should be applied using css. Examples ========= These code snippets are considered good practice and show example of well written code according to the above guidelines .. code-block:: python :caption: This snippet shows cases a well documented and annotated function with clean implementation and usage examples. def calculate_average(*values: float | int) -> float | None: """ Calculate the average of a given set of numerical values. If no values are provided, the function returns `None`. :param values: A variable number of numeric values (integers or floats) to be averaged. :return: The average of the given values, or ``None`` if no values are provided. :raises TypeError: If any value in ``values`` is not an integer or float. Example usage:: result = calculate_average(10, 20, 30) print(result) # Output: 20.0 result = calculate_average() print(result) # Output: None """ if not values: return None if not all(isinstance(v, (int, float)) for v in values): raise TypeError("All values must be integers or floats.") return sum(values) / len(values) .. code-block:: python :caption: An example Django model showcasing correct use of string localization, type annotations, and use of base model / queryset. from megforms.base_model import BaseQuerySet, BaseModel from django.utils.translation import gettext_lazy as _, gettext from typing import Self class ExampleQueryset(BaseQuerySet): def for_auditor(auditor: Auditor) -> Self: return self.filter(institution__in=auditor.institutions) class ExampleModel(BaseModel): institution = models.ForeignKey(verbose_name=_('institution'), to=Institution, on_delete=CASCADE) name = models.CharField(verbose_name=_('name'), max_length=70) objects = ExampleQueryset.as_manager() def __str__(self) -> str: return gettext("Example: {name}").format( name=self.name, ) def get_absolute_url(self) -> str: return reverse('example-page', kwargs={'example_id': self.pk}) class Meta: verbose_name = _('example') verbose_name_plural = _('examples')