From 77ebe26d52b1345e96e8af38cacc5b3581fb255d Mon Sep 17 00:00:00 2001 From: T'rahk Rokym Date: Mon, 30 Jun 2025 23:45:39 +0000 Subject: [PATCH] Application Announcement Hook --- allianceauth/admin_status/__init__.py | 0 allianceauth/admin_status/admin.py | 19 ++ allianceauth/admin_status/apps.py | 6 + allianceauth/admin_status/hooks.py | 207 ++++++++++++++++++ allianceauth/admin_status/managers.py | 57 +++++ .../admin_status/migrations/0001_initial.py | 33 +++ .../admin_status/migrations/__init__.py | 0 allianceauth/admin_status/models.py | 45 ++++ .../admin-status/celery_bar_partial.html | 0 .../templates}/admin-status/esi_check.html | 0 .../templates}/admin-status/include.html | 0 .../templates}/admin-status/overview.html | 58 +++-- .../admin_status/templatetags/__init__.py | 0 .../templatetags/admin_status.py | 32 +-- allianceauth/admin_status/tests/__init__.py | 0 allianceauth/admin_status/tests/test_hooks.py | 194 ++++++++++++++++ .../admin_status/tests/test_managers.py | 75 +++++++ .../tests/test_templatetags.py | 70 ++++-- allianceauth/authentication/views.py | 4 +- allianceauth/services/hooks.py | 11 + .../custom/app-announcement-hooks.md | 53 +++++ .../img/app_announcement_hook_example.png | Bin 0 -> 23079 bytes docs/development/custom/index.md | 1 + 23 files changed, 787 insertions(+), 78 deletions(-) create mode 100644 allianceauth/admin_status/__init__.py create mode 100644 allianceauth/admin_status/admin.py create mode 100644 allianceauth/admin_status/apps.py create mode 100644 allianceauth/admin_status/hooks.py create mode 100644 allianceauth/admin_status/managers.py create mode 100644 allianceauth/admin_status/migrations/0001_initial.py create mode 100644 allianceauth/admin_status/migrations/__init__.py create mode 100644 allianceauth/admin_status/models.py rename allianceauth/{templates/allianceauth => admin_status/templates}/admin-status/celery_bar_partial.html (100%) rename allianceauth/{templates/allianceauth => admin_status/templates}/admin-status/esi_check.html (100%) rename allianceauth/{templates/allianceauth => admin_status/templates}/admin-status/include.html (100%) rename allianceauth/{templates/allianceauth => admin_status/templates}/admin-status/overview.html (76%) create mode 100644 allianceauth/admin_status/templatetags/__init__.py rename allianceauth/{ => admin_status}/templatetags/admin_status.py (83%) create mode 100644 allianceauth/admin_status/tests/__init__.py create mode 100644 allianceauth/admin_status/tests/test_hooks.py create mode 100644 allianceauth/admin_status/tests/test_managers.py rename allianceauth/{authentication => admin_status}/tests/test_templatetags.py (80%) create mode 100644 docs/development/custom/app-announcement-hooks.md create mode 100755 docs/development/custom/img/app_announcement_hook_example.png diff --git a/allianceauth/admin_status/__init__.py b/allianceauth/admin_status/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/allianceauth/admin_status/admin.py b/allianceauth/admin_status/admin.py new file mode 100644 index 00000000..a6a10970 --- /dev/null +++ b/allianceauth/admin_status/admin.py @@ -0,0 +1,19 @@ +"""Admin site for admin status applicaton""" +from django.contrib import admin + +from allianceauth.admin_status.models import ApplicationAnnouncement + + +@admin.register(ApplicationAnnouncement) +class ApplicationAnnouncementAdmin(admin.ModelAdmin): + list_display = ["application_name", "announcement_number", "announcement_text", "hide_announcement"] + list_filter = ["hide_announcement"] + ordering = ["application_name", "announcement_number"] + readonly_fields = ["application_name", "announcement_number", "announcement_text", "announcement_url"] + fields = ["application_name", "announcement_number", "announcement_text", "announcement_url", "hide_announcement"] + + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return False diff --git a/allianceauth/admin_status/apps.py b/allianceauth/admin_status/apps.py new file mode 100644 index 00000000..35f5e6dd --- /dev/null +++ b/allianceauth/admin_status/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AdminStatusApplication(AppConfig): + name = 'allianceauth.admin_status' + label = 'admin_status' diff --git a/allianceauth/admin_status/hooks.py b/allianceauth/admin_status/hooks.py new file mode 100644 index 00000000..fc5ca081 --- /dev/null +++ b/allianceauth/admin_status/hooks.py @@ -0,0 +1,207 @@ +import hashlib +import logging +from dataclasses import dataclass +from enum import Enum +from urllib.parse import quote_plus + +import requests + +from django.core.cache import cache + +from allianceauth.hooks import get_hooks, register + +logger = logging.getLogger(__name__) + +# timeout for all requests +REQUESTS_TIMEOUT = 5 # 5 seconds +# max pages to be fetched from gitlab +MAX_PAGES = 50 +# Cache time +NOTIFICATION_CACHE_TIME = 300 # 5 minutes + + +@dataclass +class Announcement: + """ + Dataclass storing all data for an announcement to be sent arround + """ + application_name: str + announcement_url: str + announcement_number: int + announcement_text: str + + @classmethod + def build_from_gitlab_issue_dict(cls, application_name: str, gitlab_issue: dict) -> "Announcement": + """Builds the announcement from the JSON dict of a GitLab issue""" + return Announcement(application_name, gitlab_issue["web_url"], gitlab_issue["iid"], gitlab_issue["title"]) + + @classmethod + def build_from_github_issue_dict(cls, application_name: str, github_issue: dict) -> "Announcement": + """Builds the announcement from the JSON dict of a GitHub issue""" + return Announcement(application_name, github_issue["html_url"], github_issue["number"], github_issue["title"]) + + def get_hash(self): + """Get a hash of the Announcement for comparison""" + name = f"{self.application_name}.{self.announcement_number}" + hash_value = hashlib.sha256(name.encode("utf-8")).hexdigest() + return hash_value + + +@dataclass +class AppAnnouncementHook: + """ + A hook for an application to send GitHub/GitLab issues as announcements on the dashboard + + Args: + - app_name: The name of your application + - repository_namespace: The namespace of the remote repository of your application source code. + It should look like `/`. + - repository_kind: Enumeration to determine if your repository is a GitHub or GitLab repository. + - label: The label applied to issues that should be seen as announcements, case-sensitive. + Default value: `announcement` + """ + class Service(Enum): + """Simple enumeration to determine which api should be called to access issues""" + GITLAB = "gitlab" + GITHUB = "github" + + app_name: str + repository_namespace: str + repository_kind: Service + label: str = "announcement" + + + def get_announcement_list(self) -> list[Announcement]: + """ + Checks the application repository to find issues with the `Announcement` tag and return their title and link to + be displayed. + """ + logger.debug("Getting announcement list for the app %s", self.app_name) + match self.repository_kind: + case AppAnnouncementHook.Service.GITHUB: + announcement_list = self._get_github_announcement_list() + case AppAnnouncementHook.Service.GITLAB: + announcement_list = self._get_gitlab_announcement_list() + case _: + announcement_list = [] + + logger.debug("Announcements for app %s: %s", self.app_name, announcement_list) + return announcement_list + + def _get_github_announcement_list(self) -> list[Announcement]: + """ + Return the issue list for a GitHub repository + Will filter if the `pull_request` attribute is present + """ + raw_list = _fetch_list_from_github( + f"https://api.github.com/repos/{self.repository_namespace}/issues" + f"?labels={self.label}" + ) + return [Announcement.build_from_github_issue_dict(self.app_name, github_issue) for github_issue in raw_list] + + def _get_gitlab_announcement_list(self) -> list[Announcement]: + """Return the issues list for a GitLab repository""" + raw_list = _fetch_list_from_gitlab( + f"https://gitlab.com/api/v4/projects/{quote_plus(self.repository_namespace)}/issues" + f"?labels={self.label}&state=opened") + return [Announcement.build_from_gitlab_issue_dict(self.app_name, gitlab_issue) for gitlab_issue in raw_list] + +@register("app_announcement_hook") +def alliance_auth_announcements_hook(): + return AppAnnouncementHook("AllianceAuth", "allianceauth/allianceauth", AppAnnouncementHook.Service.GITLAB) + +def get_all_applications_announcements() -> list[Announcement]: + """ + Retrieve all known application announcements and returns them + """ + application_notifications = [] + + hooks = [fn() for fn in get_hooks("app_announcement_hook")] + for hook in hooks: + logger.debug(hook) + try: + application_notifications.extend(cache.get_or_set( + f"{hook.app_name}_notification_issues", + hook.get_announcement_list, + NOTIFICATION_CACHE_TIME, + )) + except requests.HTTPError: + logger.warning("Error when getting %s notifications", hook, exc_info=True) + + logger.debug(application_notifications) + if application_notifications: + application_notifications = application_notifications[:10] + + return application_notifications + + +def _fetch_list_from_gitlab(url: str, max_pages: int = MAX_PAGES) -> list: + """returns a list from the GitLab API. Supports paging""" + result = [] + + for page in range(1, max_pages + 1): + try: + request = requests.get( + url, params={'page': page}, timeout=REQUESTS_TIMEOUT + ) + request.raise_for_status() + except requests.exceptions.RequestException as e: + error_str = str(e) + + logger.warning( + f'Unable to fetch from GitLab API. Error: {error_str}', + exc_info=True, + ) + + return result + + result += request.json() + + if 'x-total-pages' in request.headers: + try: + total_pages = int(request.headers['x-total-pages']) + except ValueError: + total_pages = None + else: + total_pages = None + + if not total_pages or page >= total_pages: + break + + return result + +def _fetch_list_from_github(url: str, max_pages: int = MAX_PAGES) -> list: + """returns a list from the GitHub API. Supports paging""" + + result = [] + for page in range(1, max_pages+1): + try: + request = requests.get( + url, + params={'page': page}, + headers={ + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28" + }, + timeout=REQUESTS_TIMEOUT, + ) + request.raise_for_status() + except requests.exceptions.RequestException as e: + error_str = str(e) + + logger.warning( + f'Unable to fetch from GitHub API. Error: {error_str}', + exc_info=True, + ) + + return result + + result += request.json() + logger.debug(request.json()) + + # https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api?apiVersion=2022-11-28 + # See Example creating a pagination method + if not ('link' in request.headers and 'rel=\"next\"' in request.headers['link']): + break + + return result diff --git a/allianceauth/admin_status/managers.py b/allianceauth/admin_status/managers.py new file mode 100644 index 00000000..f445fad2 --- /dev/null +++ b/allianceauth/admin_status/managers.py @@ -0,0 +1,57 @@ +from typing import TYPE_CHECKING + +from django.db import models + +from allianceauth.admin_status.hooks import ( + Announcement, + get_all_applications_announcements, +) +from allianceauth.services.hooks import get_extension_logger + +if TYPE_CHECKING: + from .models import ApplicationAnnouncement + +logger = get_extension_logger(__name__) + +class ApplicationAnnouncementManager(models.Manager): + + def sync_and_return(self): + """ + Checks all hooks if new notifications need to be created. + Return all notification objects after + """ + logger.info("Syncing announcements") + current_announcements = get_all_applications_announcements() + self._delete_obsolete_announcements(current_announcements) + self._store_new_announcements(current_announcements) + + return self.all() + + def _delete_obsolete_announcements(self, current_announcements: list[Announcement]): + """Deletes all announcements stored in the database that aren't retrieved anymore""" + hashes = [announcement.get_hash() for announcement in current_announcements] + self.exclude(announcement_hash__in=hashes).delete() + + def _store_new_announcements(self, current_announcements: list[Announcement]): + """Stores a new database object for new application announcements""" + + for current_announcement in current_announcements: + try: + announcement = self.get(announcement_hash=current_announcement.get_hash()) + except self.model.DoesNotExist: + self.create_from_announcement(current_announcement) + else: + # if exists update the text only + if announcement.announcement_text != current_announcement.announcement_text: + announcement.announcement_text = current_announcement.announcement_text + announcement.save() + + def create_from_announcement(self, announcement: Announcement) -> "ApplicationAnnouncement": + """Creates from the Announcement dataclass""" + return self.create( + application_name=announcement.application_name, + announcement_number=announcement.announcement_number, + announcement_text=announcement.announcement_text, + announcement_url=announcement.announcement_url, + announcement_hash=announcement.get_hash(), + ) diff --git a/allianceauth/admin_status/migrations/0001_initial.py b/allianceauth/admin_status/migrations/0001_initial.py new file mode 100644 index 00000000..f7bcdc26 --- /dev/null +++ b/allianceauth/admin_status/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.9 on 2025-05-18 15:43 + +import django.db.models.manager +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='ApplicationAnnouncement', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('application_name', models.CharField(help_text='Name of the application that issued the announcement', max_length=50)), + ('announcement_number', models.IntegerField(help_text='Issue number on the notification source')), + ('announcement_text', models.TextField(help_text='Issue title text displayed on the dashboard', max_length=300)), + ('announcement_url', models.TextField(max_length=200)), + ('announcement_hash', models.CharField(default=None, editable=False, help_text='hash of an announcement. Must be nullable for unique comparison.', max_length=64, null=True, unique=True)), + ('hide_announcement', models.BooleanField(default=False, help_text='Set to true if the announcement should not be displayed on the dashboard')), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('application_name', 'announcement_number'), name='functional_pk_applicationissuenumber')], + }, + managers=[ + ('object', django.db.models.manager.Manager()), + ], + ), + ] diff --git a/allianceauth/admin_status/migrations/__init__.py b/allianceauth/admin_status/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/allianceauth/admin_status/models.py b/allianceauth/admin_status/models.py new file mode 100644 index 00000000..7b048378 --- /dev/null +++ b/allianceauth/admin_status/models.py @@ -0,0 +1,45 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from allianceauth.admin_status.managers import ApplicationAnnouncementManager + + +class ApplicationAnnouncement(models.Model): + """ + Announcement originating from an application + """ + object = ApplicationAnnouncementManager() + + application_name = models.CharField(max_length=50, help_text=_("Name of the application that issued the announcement")) + announcement_number = models.IntegerField(help_text=_("Issue number on the notification source")) + announcement_text = models.TextField(max_length=300, help_text=_("Issue title text displayed on the dashboard")) + announcement_url = models.TextField(max_length=200) + + announcement_hash = models.CharField( + max_length=64, + default=None, + unique=True, + editable=False, + help_text="hash of an announcement." + ) + + hide_announcement = models.BooleanField( + default=False, + help_text=_("Set to true if the announcement should not be displayed on the dashboard") + ) + + class Meta: + # Should be updated to a composite key when the switch to Django 5.2 is made + # https://docs.djangoproject.com/en/5.2/topics/composite-primary-key/ + constraints = [ + models.UniqueConstraint( + fields=["application_name", "announcement_number"], name="functional_pk_applicationissuenumber" + ) + ] + + def __str__(self): + return f"{self.application_name} announcement #{self.announcement_number}" + + def is_hidden(self) -> bool: + """Function in case rules are made in the future to force hide/force show some announcements""" + return self.hide_announcement diff --git a/allianceauth/templates/allianceauth/admin-status/celery_bar_partial.html b/allianceauth/admin_status/templates/admin-status/celery_bar_partial.html similarity index 100% rename from allianceauth/templates/allianceauth/admin-status/celery_bar_partial.html rename to allianceauth/admin_status/templates/admin-status/celery_bar_partial.html diff --git a/allianceauth/templates/allianceauth/admin-status/esi_check.html b/allianceauth/admin_status/templates/admin-status/esi_check.html similarity index 100% rename from allianceauth/templates/allianceauth/admin-status/esi_check.html rename to allianceauth/admin_status/templates/admin-status/esi_check.html diff --git a/allianceauth/templates/allianceauth/admin-status/include.html b/allianceauth/admin_status/templates/admin-status/include.html similarity index 100% rename from allianceauth/templates/allianceauth/admin-status/include.html rename to allianceauth/admin_status/templates/admin-status/include.html diff --git a/allianceauth/templates/allianceauth/admin-status/overview.html b/allianceauth/admin_status/templates/admin-status/overview.html similarity index 76% rename from allianceauth/templates/allianceauth/admin-status/overview.html rename to allianceauth/admin_status/templates/admin-status/overview.html index 47484a57..9970328d 100644 --- a/allianceauth/templates/allianceauth/admin-status/overview.html +++ b/allianceauth/admin_status/templates/admin-status/overview.html @@ -2,44 +2,24 @@ {% load humanize %} {% if notifications %} -
+
- {% translate "Alliance Auth Notifications" as widget_title %} + {% translate "Announcements" as widget_title %} {% include "framework/dashboard/widget-title.html" with title=widget_title %}
- - + {# TODO maybe add some disclaimer that those are managed by application devs? #}
@@ -107,9 +87,9 @@ style="height: 21px;" title="{{ tasks_succeeded|intcomma }} succeeded, {{ tasks_retried|intcomma }} retried, {{ tasks_failed|intcomma }} failed" > - {% include "allianceauth/admin-status/celery_bar_partial.html" with label="suceeded" level="success" tasks_count=tasks_succeeded %} - {% include "allianceauth/admin-status/celery_bar_partial.html" with label="retried" level="info" tasks_count=tasks_retried %} - {% include "allianceauth/admin-status/celery_bar_partial.html" with label="failed" level="danger" tasks_count=tasks_failed %} + {% include "admin-status/celery_bar_partial.html" with label="suceeded" level="success" tasks_count=tasks_succeeded %} + {% include "admin-status/celery_bar_partial.html" with label="retried" level="info" tasks_count=tasks_retried %} + {% include "admin-status/celery_bar_partial.html" with label="failed" level="danger" tasks_count=tasks_failed %}

@@ -118,6 +98,20 @@

+ diff --git a/allianceauth/admin_status/templatetags/__init__.py b/allianceauth/admin_status/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/allianceauth/templatetags/admin_status.py b/allianceauth/admin_status/templatetags/admin_status.py similarity index 83% rename from allianceauth/templatetags/admin_status.py rename to allianceauth/admin_status/templatetags/admin_status.py index d7084dc5..af1f8f48 100644 --- a/allianceauth/templatetags/admin_status.py +++ b/allianceauth/admin_status/templatetags/admin_status.py @@ -8,6 +8,7 @@ from django.conf import settings from django.core.cache import cache from allianceauth import __version__ +from allianceauth.admin_status.models import ApplicationAnnouncement from allianceauth.authentication.task_statistics.counters import ( dashboard_results, ) @@ -25,10 +26,6 @@ MAX_PAGES = 50 GITLAB_AUTH_REPOSITORY_TAGS_URL = ( 'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/repository/tags' ) -GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL = ( - 'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/issues' - '?labels=announcement&state=opened' -) logger = logging.getLogger(__name__) @@ -41,7 +38,7 @@ def decimal_widthratio(this_value, max_value, max_width) -> str: return str(round(this_value / max_value * max_width, 2)) -@register.inclusion_tag('allianceauth/admin-status/overview.html') +@register.inclusion_tag('admin-status/overview.html') def status_overview() -> dict: response = { "notifications": [], @@ -73,32 +70,15 @@ def _celery_stats() -> dict: def _current_notifications() -> dict: - """returns the newest 5 announcement issues""" - try: - notifications = cache.get_or_set( - 'gitlab_notification_issues', - _fetch_notification_issues_from_gitlab, - NOTIFICATION_CACHE_TIME - ) - except requests.HTTPError: - logger.warning('Error while getting gitlab notifications', exc_info=True) - top_notifications = [] - else: - if notifications: - top_notifications = notifications[:5] - else: - top_notifications = [] + """returns announcements from AllianceAuth and third party applications""" + + application_notifications = ApplicationAnnouncement.object.sync_and_return() response = { - 'notifications': top_notifications, + 'notifications': application_notifications, } return response - -def _fetch_notification_issues_from_gitlab() -> list: - return _fetch_list_from_gitlab(GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL, max_pages=10) - - def _current_version_summary() -> dict: """returns the current version info""" try: diff --git a/allianceauth/admin_status/tests/__init__.py b/allianceauth/admin_status/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/allianceauth/admin_status/tests/test_hooks.py b/allianceauth/admin_status/tests/test_hooks.py new file mode 100644 index 00000000..5a1ab8fd --- /dev/null +++ b/allianceauth/admin_status/tests/test_hooks.py @@ -0,0 +1,194 @@ +import requests_mock + +from allianceauth.admin_status.hooks import Announcement +from allianceauth.services.hooks import AppAnnouncementHook +from allianceauth.utils.testing import NoSocketsTestCase + + +class TestHooks(NoSocketsTestCase): + + @requests_mock.mock() + def test_fetch_gitlab(self, requests_mocker): + # given + announcement_hook = AppAnnouncementHook("test GitLab app", "r0kym/allianceauth-example-plugin", + AppAnnouncementHook.Service.GITLAB) + requests_mocker.get( + "https://gitlab.com/api/v4/projects/r0kym%2Fallianceauth-example-plugin/issues?labels=announcement&state=opened", + json=[ + { + "id": 166279127, + "iid": 1, + "project_id": 67653102, + "title": "Test GitLab issue", + "description": "Test issue", + "state": "opened", + "created_at": "2025-04-20T21:26:57.914Z", + "updated_at": "2025-04-21T11:04:30.501Z", + "closed_at": None, + "closed_by": None, + "labels": [ + "announcement" + ], + "milestone": None, + "assignees": [], + "author": { + "id": 14491514, + "username": "r0kym", + "public_email": "", + "name": "T'rahk Rokym", + "state": "active", + "locked": False, + "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/14491514/avatar.png", + "web_url": "https://gitlab.com/r0kym" + }, + "type": "ISSUE", + "assignee": None, + "user_notes_count": 0, + "merge_requests_count": 0, + "upvotes": 0, + "downvotes": 0, + "due_date": None, + "confidential": False, + "discussion_locked": None, + "issue_type": "issue", + "web_url": "https://gitlab.com/r0kym/allianceauth-example-plugin/-/issues/1", + "time_stats": { + "time_estimate": 0, + "total_time_spent": 0, + "human_time_estimate": None, + "human_total_time_spent": None + }, + "task_completion_status": { + "count": 0, + "completed_count": 0 + }, + "blocking_issues_count": 0, + "has_tasks": True, + "task_status": "0 of 0 checklist items completed", + "_links": { + "self": "https://gitlab.com/api/v4/projects/67653102/issues/1", + "notes": "https://gitlab.com/api/v4/projects/67653102/issues/1/notes", + "award_emoji": "https://gitlab.com/api/v4/projects/67653102/issues/1/award_emoji", + "project": "https://gitlab.com/api/v4/projects/67653102", + "closed_as_duplicate_of": None + }, + "references": { + "short": "#1", + "relative": "#1", + "full": "r0kym/allianceauth-example-plugin#1" + }, + "severity": "UNKNOWN", + "moved_to_id": None, + "imported": False, + "imported_from": "none", + "service_desk_reply_to": None + } + ] + ) + # when + announcements = announcement_hook.get_announcement_list() + # then + self.assertEqual(len(announcements), 1) + self.assertIn(Announcement( + application_name="test GitLab app", + announcement_url="https://gitlab.com/r0kym/allianceauth-example-plugin/-/issues/1", + announcement_number=1, + announcement_text="Test GitLab issue" + ), announcements) + + @requests_mock.mock() + def test_fetch_github(self, requests_mocker): + # given + announcement_hook = AppAnnouncementHook("test GitHub app", "r0kym/test", AppAnnouncementHook.Service.GITHUB) + requests_mocker.get( + "https://api.github.com/repos/r0kym/test/issues?labels=announcement", + json=[ + { + "url": "https://api.github.com/repos/r0kym/test/issues/1", + "repository_url": "https://api.github.com/repos/r0kym/test", + "labels_url": "https://api.github.com/repos/r0kym/test/issues/1/labels{/name}", + "comments_url": "https://api.github.com/repos/r0kym/test/issues/1/comments", + "events_url": "https://api.github.com/repos/r0kym/test/issues/1/events", + "html_url": "https://github.com/r0kym/test/issues/1", + "id": 3007269496, + "node_id": "I_kwDOOc2YvM6zP0p4", + "number": 1, + "title": "GitHub issue", + "user": { + "login": "r0kym", + "id": 56434393, + "node_id": "MDQ6VXNlcjU2NDM0Mzkz", + "avatar_url": "https://avatars.githubusercontent.com/u/56434393?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/r0kym", + "html_url": "https://github.com/r0kym", + "followers_url": "https://api.github.com/users/r0kym/followers", + "following_url": "https://api.github.com/users/r0kym/following{/other_user}", + "gists_url": "https://api.github.com/users/r0kym/gists{/gist_id}", + "starred_url": "https://api.github.com/users/r0kym/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/r0kym/subscriptions", + "organizations_url": "https://api.github.com/users/r0kym/orgs", + "repos_url": "https://api.github.com/users/r0kym/repos", + "events_url": "https://api.github.com/users/r0kym/events{/privacy}", + "received_events_url": "https://api.github.com/users/r0kym/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": False + }, + "labels": [ + { + "id": 8487814480, + "node_id": "LA_kwDOOc2YvM8AAAAB-enFUA", + "url": "https://api.github.com/repos/r0kym/test/labels/announcement", + "name": "announcement", + "color": "aaaaaa", + "default": False, + "description": None + } + ], + "state": "open", + "locked": False, + "assignee": None, + "assignees": [], + "milestone": None, + "comments": 0, + "created_at": "2025-04-20T22:41:10Z", + "updated_at": "2025-04-21T11:05:08Z", + "closed_at": None, + "author_association": "OWNER", + "active_lock_reason": None, + "sub_issues_summary": { + "total": 0, + "completed": 0, + "percent_completed": 0 + }, + "body": None, + "closed_by": None, + "reactions": { + "url": "https://api.github.com/repos/r0kym/test/issues/1/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "timeline_url": "https://api.github.com/repos/r0kym/test/issues/1/timeline", + "performed_via_github_app": None, + "state_reason": None + } + ] + ) + # when + announcements = announcement_hook.get_announcement_list() + # then + self.assertEqual(len(announcements), 1) + self.assertIn(Announcement( + application_name="test GitHub app", + announcement_url="https://github.com/r0kym/test/issues/1", + announcement_number=1, + announcement_text="GitHub issue" + ), announcements) diff --git a/allianceauth/admin_status/tests/test_managers.py b/allianceauth/admin_status/tests/test_managers.py new file mode 100644 index 00000000..9735e00d --- /dev/null +++ b/allianceauth/admin_status/tests/test_managers.py @@ -0,0 +1,75 @@ +from unittest.mock import patch + +from allianceauth.admin_status.hooks import Announcement +from allianceauth.admin_status.models import ApplicationAnnouncement +from allianceauth.utils.testing import NoSocketsTestCase + +MODULE_PATH = 'allianceauth.admin_status.managers' + +DEFAULT_ANNOUNCEMENTS = [ + Announcement( + application_name="Test GitHub Application", + announcement_number=1, + announcement_text="GitHub issue", + announcement_url="https://github.com/r0kym/test/issues/1", + ), + Announcement( + application_name="Test Gitlab Application", + announcement_number=1, + announcement_text="GitLab issue", + announcement_url="https://gitlab.com/r0kym/allianceauth-example-plugin/-/issues/1", + ) +] + +class TestSyncManager(NoSocketsTestCase): + + def setUp(self): + ApplicationAnnouncement.object.create( + application_name="Test GitHub Application", + announcement_number=1, + announcement_text="GitHub issue", + announcement_url="https://github.com/r0kym/test/issues/1", + announcement_hash="9dbedb9c47529bb43cfecb704768a35d085b145930e13cced981623e5f162a85", + ) + ApplicationAnnouncement.object.create( + application_name="Test Gitlab Application", + announcement_number=1, + announcement_text="GitLab issue", + announcement_url="https://gitlab.com/r0kym/allianceauth-example-plugin/-/issues/1", + announcement_hash="8955a9c12a1cfa9e1776662bdaf111147b84e35c79f24bfb758e35333a18b1bd", + ) + + @patch(MODULE_PATH + '.get_all_applications_announcements') + def test_announcements_stay_as_is(self, all_announcements_mocker): + # given + announcement_ids = set(ApplicationAnnouncement.object.values_list("id", flat=True)) + all_announcements_mocker.return_value = DEFAULT_ANNOUNCEMENTS + # when + ApplicationAnnouncement.object.sync_and_return() + # then + self.assertEqual(ApplicationAnnouncement.object.count(), 2) + self.assertEqual(set(ApplicationAnnouncement.object.values_list("id", flat=True)), announcement_ids) + + @patch(MODULE_PATH + '.get_all_applications_announcements') + def test_announcement_add(self, all_announcements_mocker): + # given + returned_announcements = DEFAULT_ANNOUNCEMENTS + [Announcement(application_name="Test Application", announcement_number=1, announcement_text="New test announcement", announcement_url="https://example.com")] + all_announcements_mocker.return_value = returned_announcements + # when + ApplicationAnnouncement.object.sync_and_return() + # then + self.assertEqual(ApplicationAnnouncement.object.count(), 3) + self.assertTrue(ApplicationAnnouncement.object.filter(application_name="Test Application", announcement_number=1, announcement_text="New test announcement", announcement_url="https://example.com")) + + @patch(MODULE_PATH + '.get_all_applications_announcements') + def test_announcement_remove(self, all_announcements_mocker): + # given + all_announcements_mocker.return_value = DEFAULT_ANNOUNCEMENTS + ApplicationAnnouncement.object.sync_and_return() + self.assertEqual(ApplicationAnnouncement.object.count(), 2) + all_announcements_mocker.return_value = DEFAULT_ANNOUNCEMENTS[:1] + # when + ApplicationAnnouncement.object.sync_and_return() + # then + self.assertEqual(ApplicationAnnouncement.object.count(), 1) + self.assertTrue(ApplicationAnnouncement.object.filter(application_name="Test GitHub Application").exists()) diff --git a/allianceauth/authentication/tests/test_templatetags.py b/allianceauth/admin_status/tests/test_templatetags.py similarity index 80% rename from allianceauth/authentication/tests/test_templatetags.py rename to allianceauth/admin_status/tests/test_templatetags.py index 7f42f830..9f5d6234 100644 --- a/allianceauth/authentication/tests/test_templatetags.py +++ b/allianceauth/admin_status/tests/test_templatetags.py @@ -8,23 +8,61 @@ from packaging.version import Version as Pep440Version from django.core.cache import cache from django.test import TestCase -from allianceauth.templatetags.admin_status import ( +from allianceauth.admin_status.models import ApplicationAnnouncement +from allianceauth.admin_status.templatetags.admin_status import ( _current_notifications, _current_version_summary, _fetch_list_from_gitlab, - _fetch_notification_issues_from_gitlab, _latests_versions, status_overview, ) -MODULE_PATH = 'allianceauth.templatetags' +MODULE_PATH = 'allianceauth.admin_status.templatetags' def create_tags_list(tag_names: list): return [{'name': str(tag_name)} for tag_name in tag_names] +def get_app_announcement_as_dict(app_announcement: ApplicationAnnouncement) -> dict: + """Transforms an app announcement object in a dict easy to compare""" + return { + "application_name": app_announcement.application_name, + "announcement_number": app_announcement.announcement_number, + "announcement_text": app_announcement.announcement_text, + "announcement_url": app_announcement.announcement_url, + } + GITHUB_TAGS = create_tags_list(['v2.4.6a1', 'v2.4.5', 'v2.4.0', 'v2.0.0', 'v1.1.1']) +STORED_NOTIFICATIONS = [ + ApplicationAnnouncement( + application_name="Test GitHub Application", + announcement_number=1, + announcement_text="GitHub issue", + announcement_url="https://github.com/r0kym/test/issues/1", + announcement_hash="hash1", + ), + ApplicationAnnouncement( + application_name="Test Gitlab Application", + announcement_number=1, + announcement_text="GitLab issue", + announcement_url="https://gitlab.com/r0kym/allianceauth-example-plugin/-/issues/1", + announcement_hash="hash2", + ), +] +ANNOUNCEMENT_DICT = [ + { + "application_name": "Test GitHub Application", + "announcement_number": 1, + "announcement_text": "GitHub issue", + "announcement_url": "https://github.com/r0kym/test/issues/1", + }, { + "application_name": "Test Gitlab Application", + "announcement_number": 1, + "announcement_text": "GitLab issue", + "announcement_url": "https://gitlab.com/r0kym/allianceauth-example-plugin/-/issues/1", + } +] GITHUB_NOTIFICATION_ISSUES = [ { 'id': 1, @@ -52,6 +90,10 @@ GITHUB_NOTIFICATION_ISSUES = [ }, ] TEST_VERSION = '2.6.5' +GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL = ( + 'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/issues' + '?labels=announcement&state=opened' +) class TestStatusOverviewTag(TestCase): @@ -107,18 +149,19 @@ class TestNotifications(TestCase): ) requests_mocker.get(url, json=GITHUB_NOTIFICATION_ISSUES) # when - result = _fetch_notification_issues_from_gitlab() + result = _fetch_list_from_gitlab(GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL, 10) # then self.assertEqual(result, GITHUB_NOTIFICATION_ISSUES) - @patch(MODULE_PATH + '.admin_status.cache') - def test_current_notifications_normal(self, mock_cache): + @patch(MODULE_PATH + '.admin_status.ApplicationAnnouncement') + def test_current_notifications_normal(self, mock_application_announcement): # given - mock_cache.get_or_set.return_value = GITHUB_NOTIFICATION_ISSUES + mock_application_announcement.object.sync_and_return.return_value = STORED_NOTIFICATIONS # when result = _current_notifications() # then - self.assertEqual(result['notifications'], GITHUB_NOTIFICATION_ISSUES[:5]) + for notification in result["notifications"]: + self.assertIn(get_app_announcement_as_dict(notification), ANNOUNCEMENT_DICT) @requests_mock.mock() def test_current_notifications_failed(self, requests_mocker): @@ -131,16 +174,7 @@ class TestNotifications(TestCase): # when result = _current_notifications() # then - self.assertEqual(result['notifications'], []) - - @patch(MODULE_PATH + '.admin_status.cache') - def test_current_notifications_is_none(self, mock_cache): - # given - mock_cache.get_or_set.return_value = None - # when - result = _current_notifications() - # then - self.assertEqual(result['notifications'], []) + self.assertEqual(list(result['notifications']), []) class TestCeleryQueueLength(TestCase): diff --git a/allianceauth/authentication/views.py b/allianceauth/authentication/views.py index b5983a27..63c2d6c7 100644 --- a/allianceauth/authentication/views.py +++ b/allianceauth/authentication/views.py @@ -74,14 +74,14 @@ def dashboard_characters(request): def dashboard_admin(request): if request.user.is_superuser: - return render_to_string('allianceauth/admin-status/include.html', request=request) + return render_to_string('admin-status/include.html', request=request) else: return "" def dashboard_esi_check(request): if request.user.is_superuser: - return render_to_string('allianceauth/admin-status/esi_check.html', request=request) + return render_to_string('admin-status/esi_check.html', request=request) else: return "" diff --git a/allianceauth/services/hooks.py b/allianceauth/services/hooks.py index a2da7827..7e76d6d5 100644 --- a/allianceauth/services/hooks.py +++ b/allianceauth/services/hooks.py @@ -6,6 +6,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.urls import include, re_path from django.utils.functional import cached_property +from allianceauth.admin_status.hooks import AppAnnouncementHook from allianceauth.hooks import get_hooks from allianceauth.menu.hooks import MenuItemHook @@ -145,6 +146,16 @@ class MenuItemHook(MenuItemHook): def __init_subclass__(cls) -> None: return super().__init_subclass__() +class AppAnnouncementHook(AppAnnouncementHook): + """ + AppAnnouncementHook shim to allianceauth.admin_status.hooks + + :param AppAnnouncementHook: _description_ + :type AppAnnouncementHook: _type_ + """ + def __init_subclass__(cls) -> None: + return super().__init_subclass__() + class UrlHook: """A hook for registering the URLs of a Django app. diff --git a/docs/development/custom/app-announcement-hooks.md b/docs/development/custom/app-announcement-hooks.md new file mode 100644 index 00000000..e4992864 --- /dev/null +++ b/docs/development/custom/app-announcement-hooks.md @@ -0,0 +1,53 @@ +# Announcement Hooks + +This hook allows the issues opened on your application repository to be displayed on the alliance auth front page to +administrators. + +![app_announcement_hook_example](img/app_announcement_hook_example.png) + +To register an AppAnnouncementHook class, you would do the following: + +```python +from allianceauth import hooks +from allianceauth.services.hooks import AppAnnouncementHook + + +@hooks.register('app_announcement_hook') +def announcement_hook(): + return AppAnnouncementHook("Your app name", "USERNAME/REPOSITORY_NAME", AppAnnouncementHook.Service.GITLAB) +``` + +```{eval-rst} +.. autoclass:: allianceauth.services.hooks.AppAnnouncementHook + :members: __init__ + :undoc-members: +``` + +## Parameters + +### app_name + +The name of your application. + +### repository_namespace + +Here you should enter the namespace of your repository. +The structure stays the same for both GitHub and GitLab repositories. \ +A repository with the url `https://gitlab.com/username/appname` will have a namespace of `username/appname`. + +### Service + +This variable is an enumeration of the class `AppAnnouncemementHook.Service` + +It is mandatory to specify this variable so alliance auth contacts the correct API when fetching your repository issues. + +```{eval-rst} +.. autoclass:: allianceauth.services.hooks.AppAnnouncementHook.Service + :members: GITLAB, GITHUB + :undoc-members: +``` + +### label + +The label that will determine if issues should be seen as an announcement. +This value is case-sensitive and the default value is `"announcement"`. diff --git a/docs/development/custom/img/app_announcement_hook_example.png b/docs/development/custom/img/app_announcement_hook_example.png new file mode 100755 index 0000000000000000000000000000000000000000..6b9b494417a853778c87cb3f7b792842bda80bf2 GIT binary patch literal 23079 zcmdSAcT`i&8!oC;0RidKrT5+?fYOz&(mM#DSAo!_hhCHpp-J!3yL6EfklrB?kWT15 zC;ENA-#KTk^XIvD-8*X~WN-F<_w0AxnR(}Vo{7{{SHj1o!hP`I0sbpx`8N+9pg~c; zcOPM)zEj41J5hfgy1!A9eNZ(M}qmY0{uw?D9Rami_E5#i$EivPFprZZ4!*O%GoOjPBYC%>VA z@}}bXbkHRyC-YOq)-3Md>oWW?)XEIjAsgjf&=#(+(Pts;uPgCDMLv(Cf<-jV`F3apFX}f{ipgQ^21R@ zv^)w3BNLM`{l7J)s{7A5Zs}U(zt&SYYz-6;-0D{^|9kk)Ysad*&Pe&^h2cp_MOFZk zKfY-G^2L_&ub+f@QFs1-?9#voI7ODumfbP)X`=qpbSnmU#YkP8^JUq4Df)m!^@*luAQ~&CV-+fR=LFBxIPpd_nHtwmlm9dl64 z3}ka^v*j%sZAV9bufH;lRZcu8aJCrxfO3Gb_&>&xBhs=WDs=A2%uSXQMpI@UL})b0fbNJKiES(->U>>haVQe5Xta>PaU*bXTk* z0^<_tK4+uwa+J48(f(-)1}n8&3>DzWz_g0=H(P<}J@)Rv!|Vv#N$0+|ZKp0*t1o^} zTJ++(^PhC)Xs*@B<c>w-nXiIo}HvL5-riXa&Aa z8%J8obVdvwv|^rYjSVhYUBF87pLNoi|BgWt`E@bGI%zU`e zv`!bhd+t%h&BpwxgmboH4%8|*G_HfsViWcL2tACQ8~QhbX3&!p0mtr1u$@cKOafSq zUOHl0jIX;wLd`RlbFVU1)pJsm#qCpdKenLn`OWqb%<2Fl-8K@q9GPTlPiv+(O=jdeJB z^1)40(=(ABcYr%`zd2y#ER8L08`zfMOv9*O*&u#*Gk7_C$K~34dzIF*MqXiX!P_bG zCY-k`bI$!f)(~hCv5`~MvndQS+6z3|Dey9%2;LPq314(M99~^}QQE)j%`bNcZP2Ub zek`;>n13Bt-YXqEBkgi%^D4ea2`U}BA;$YHrX~7`=d4frc}|Epa7{@!(k9_=H`4m8 z%u3_y&QS%CrWlq{JL#(;nKq>BjoGdfIGJ!P!I|N88yOv_m)NaAM6BfgmxMen(Zd;?Y>L2uAeW=``T#fcp(*ZH-_2lxh@5QH4q*hEb>{5S`h8k32f!j zCvKT$G;;0*+!084_Rqg?(!1iP1N{zI`Vev>I=Q(TPpBd4o6YbG4emYg=60h_!*w;d zC9TPg)@0#`VI^Q&LyvJLOd}^gIfu2oNy*qI8cBS?eH6Ox(PXv}5;%bFwHn@HK_001 zl)x$ANtUWuf)TqXJ^c2 zg4>Sf?CH&V?KRelyz2xUXJ{{dWhDqYT_ua4EtFm|C*-0IAj%8w7WEZ~cl>=E+N!Rg zi!h{Tpv^gfFQ?&_0f6|q3%a^ubJ@j*8H1@^w%DlZeLCV?2TvSSyO933Ct-{H{$py* zH|hAGt-Ge$gE4+1o^z{-I&jGjA55Bb|C$Z;1X@Dw>=4_@Eek|}^I9eT;ShXZ<2+wJ zfCwfF&ugmjOA%McbDivk!saO;{pd}+eIhX5ql3dl>Prmb3Gu>z1`5G9_S_J%ZZUWK z&L6aCSfS+{Si8U2+vry*u+y#g<6E}tTt}0-)^7T&t6wzaAI%PKeC)K|nEn2=IadbK zzfW#9Em_MhK!}z9Hjvo4^2xd<(>+WG?x--3dhtEadw;ar!d@L4tFfdS2Z7X&*zF(L z3@)yzEJG&Y#QvY${93_4~Wp~3FsONLta&Vnzx-4|#pEg#j1xc%7z|fWu-7i09 zU>`hLZa`e__jXw>w%l*O=MwDfq(BV=-LV??aWU%EaQj)go9YODUsPD#+ETUNbRBrK z16OYdv+VW)tC-V%-iIaESzB&?C2MxQH}FfgrAoW=Xn^z?Hi0c$AMx#77zmp44LZlK zfNdu!U_pE&3(5kp!{Aj^Cda{i%*S4+m1fZsYUdwA+g{WaET|_&zSTBM2!Z#g&Ej?ANLU>oM%Ibn`k&N1&PkFGPnjn>{Tp> zM_9Kxr}{xGmE`}Kn@Oq0aBojPf~&6d+8V|V>x;Nk5Pk|3GJXDw!rQ6nuDUtoTw}fDQ-PZ74TOSUWyE3?!PCw< z=${tf6?$N_qLg680k13{@(sZHAl^eWiE4J+aW zPpPdH=$U{`idP9XaD}X{Z+86v`95D@4PiLGk?d&RSTBMH(96bE1g{|IE<|zlZP0o1)YEb|JlSWcLBe0mpMe*(xe4hA1OyaairXJ@&o5=J1t$nhu z&P8%>=kUXJ@p94i03s*%8B4>Exl|cm2#JgVZ#7ZZRkXu}TX2mCUBYNS4#7e|a9U33)z7vn;!`UAnDPBIj*{Qd$z4-lBC!MxjGO z0#-myV7DADzcIYIeJLS-2q^r$;!fAWSp_gB`pQc>+6&o+m*G~hiuaI_(duu4^W^u- zpMy;;OiuUv&0Dn|AYn5=_##CV2ofs+-3!++C8T!Giv?cTj#*pYOJ+PH+S{~S2^v&r z9u0UZlM+dNe+Zi|T#fWNwyf@ll0CH7x}8frpRYV<=;m|m=5Pc|$vsw+<=weSeMRWo zu0#oq6TDr$gSE8K#rp@cuDN0>q16M+vBz2w5&d;*doMgkwl0T_qdQFWxW65rnZosq z*Xoqy;i-S8!3oSyt?DhSDHl1mGVWN;5AOv`K_B$c4zF)tAlJL6_ zbeFehUP4K+EfFQwIM&>?km=*FSj_g|e*S-f$XQHPQVeD&*P7O4i0DA^<^-1XSGT z_3d&~^!6LE!@XTb4;sq5lxiY~x!G2`&nyB{{9-V?W2;E4x&YiVGZnKMm)Xt+kBF0x zQ_1@3mg_owug^esx{F7y>^ki9zId~kS&_d0;ZiFFF~GURo%FlT3FOGr-pic6I*K+Y z8509R>`Cj2Z-b>T=@P&LEdN9(9{eYRUpBb1Mekl8*^(UrlOLTK+?eb$cE3se{#)j* zuSS642MEh3R`RIN<3OZ8CmUZ7lcT{L$#pRKC9I?RtS>ioY%X>AqQMid3{B^)xMtl3 zB1kCENE1*%m~PrUD**NTpfX;k3;Og}2~nm;AOvT&ii`3hCdIw3+2zm2NY3PDTc~0jJ1rSGU=E9da_UdUh;rUg~oa%jPEOyo&}Z# z)d0IoLz^)V?W88BdIyYb;FP}=7rJJ?H)RIg7@|^fe8tKR{OvA4NEi$Nos|h1O%7Rv zmBshv^tnapm)8n(y7MnE^6w$&`~kiC z5&If^DYHq?mJ3T7_NK=N<-MUlKgqzuBvasdIk;H&g848|IHqeL+16)BNWU|^QkzPa zO6#MHy4YjHC+?QHuZXv|kh$L;b}w*Jf=z2ZJ3;OE;?`8IZX^>{>aQjRA(6k)<`Q=$ z4i6jb?+G#QX~|=z;-vWgxMbjVHm>K{bL+V@JP$y0=}$NLuCG7uuyw4LKGui?_Amt+ zL@!cAk7vi)o*WEFDpC7G$DXApo=@K$->GG~3my1x`?p`LWa>3pa}=3+iJ2gOS#hUR zW`M|VWk(l}38QAyGNg}sj^1+Ey zCm|u=nytcwX!D9mP|R#Y(zWES0H)vPWX~uaXp=}YK55PUV$RqeG0sG2-p>dX$aa^6 z#B);WrB(tU#^Z;9m?s-D6H*lJsg?3`w=K=r1G+W3hq7Kfx)8W1z4?Vygsxb7e3w)g zxymn(UrlRaQXYU^EFyf(zmxWxDmF6up}oLM9~kpK7i0s`FdMqsbf!3HHRd8gYX{TbdN9GS5`l`<(M?!{s?oa z&Vs`l+DTAHUezZB^$&L}T?uSS0xRqP2^ZAr)W+z|U&o-30IJl_!aV=r5&sSde{crZ zSAQex|KFGHvyl9OKROzv>%B6@$ETte!mX!V{!?Ox84NUq^K9fM!1-iyYOf|uh!J}q zTFivg^XzRSTH3G)iYJkre<6x7Fe|AeJC#;_zRiFp%5xM_5SCZL-E(1$>P!WHp&T@@ zB55UihE~003A^n=G#yl`qr>KBlTg-QZ!71MDF{V*J|<&P^PPa}w@2}N8QyjMMRtD4 z2^ZK5YHIy8>M`n=#%pse<~)= zMD)Y+-G5tBNOnH%zX*z@BKxA&e;aY+@2&pUzWf2;~HecmtmFT`RU`js|W zc;AW=x{a^n0GJG())LXHFHK^?kfTv?Rqd~3LEY1;_xDNXU!%92KP{s5#52{)(OCX5 z7R~V27+*QaS{!+Zg`V*T3JKS2EJ!NqaDeG^vmFrv;$%FH=7lHaVj z1{Ok@=H4gYRTN6KR8<{)(YU(&N*(=NJ8OV;NGq*uIZh#+ z%5Tyx7zvqE1J>$w;Okz-4$Ujr9JM*|XZwg)mo%B%sSA9#r@#_e*o{}^^V|%>-s>gX zp#>FBZUk=Ry{fzfXor>K+Ks~l=UHCLzKtd~$MHq9u0(0=QO0J_Zc%+Ydhx^4sBk%G zkg9d9*V!Xf_~;vri&4^H;!(8gn;eQIhohoZVOXDlsiTvIifi0jKA|(z~sG`YkShQy7ra zh(0Myl$IKRi?rL9twv4#HvGYh_%cD{ zyRLWeICwlQER74;d(f`T&FP#N=sN#ANr3NO*dpYXf1m$7JV;)n9l{4;!8^~2?q*g? z0C{?>6<6%1JqdhJ{Vvta*9=50j$l*`h)1j!mgi`x~w_^6ZnuP1_7u$KDs zCsl8h)*#Q}^iSO3AK!mNTFI~X&#cEo8idHqGnALK46rIo(a+AG_*Y*a_0?JP-5Z#O zg3mY0@LwM8?*oOPa>(calWZ2y517#EF3+RXq45reG=YtxO zH^e7Pd*i;!rB%&G$3NbZ#KmnQQ_m`w3f|taL77&H>7KTFRsWD<0?pfx6l?LIVEHy#o0gg0_; zT9oKI4R^*NLaj!;rqorzgOTf1p551Q34*S35b{Mr?yS8_w!tm+_EXj;cV~>Oe2n}_ zd_f@3Gjh0jC2o^;qff^TOWxN5{vw-mIJNr`1m88&rSA2z$@RI@%2czBHem%FCND;_bAX3uWV-s;j9NKGi7K(Lx)* zs}JQ1NXk4(Q3IM43H07vY;SG`><{bU848k$DEH$v^sK;RRUXeO?a3eYxO_<{j-Rd9 zRY)A&)U=j1A66F3y0jUXgY0!Rbt80uDOp)kA1u>=u51RfadhTbussJcO+=IOKEEfC z(Y#nUHHHlqk+4NIT3TymYh=AT=LDoa3EEIdE^(tm1XA`6*3G9yE;dLaqB^My8?gQ1 z`hsg+u=DgS&Hal{3+7`zxN72bb~PaDWXHKr^g$9V&#%Pe<3eFOcWGaw;@A!O2*@1< z`#uYQ!Jn@#=(ztb(JEM@>ie|o1_O*{$q3n&ZT7)KeB8W6_!Ws$k-Qyagrjv8hKs!e z_y&uaXSVy<_Z|Aa+P~!F0sQs6eVJAa+X#<6H-komwqEz9RY(N= zmR_x%orW?43*oky(+6k4Z>aB+SyLW>&$4)9c4#fUOG9xd zq1t)q9}A_3PJ{(Oby90u%Zk4Kr|X~z0#$YO@CBq-a@|Afq~6-*c7?bA%VAqp73Drk zinY&1Oh#kR@(P9MtE~?P9y^Lxsm1lK?%*C3X#k=drijvX^W_Y6iA+Z4BJG6}^|VUq z#T!}OF{OuPz+pH%kd z7W-Gk`$V@+2j3uW&};R5f{O3MeYKEo-KUMH{_yUwPch`i9=&PTuK)ZiTrcr==korR zTjJVXXsar?{6lq6&fZ0bIKvsY;9ZXIP*kV^&jFErfJkCmrYFte?&+~R+se#JATJup zZ7LpS`{i$$fU}bAkwMcsPs<`vw8^&w@Uw88i;ElfbK|E7gwwX-D|bOU<;WiGg=|n; z2O+SWz${K@qwbJo@@v9Y2Vs>#JIx`|S%il0yld?SVu$|OkwW9kDeQjtr?6m+|JS7 zhHvWNTicwkpYR?YqGQ*yND2sWP$J|yFFjuv6Nf}bQqkaQ)e>}75`Vx~63};l+Jy;7 zb-ZL$3!_vx!)4{LX~)x+#C@INNi*OsBC6A}CY*U>7>TZ)iFp`7Xd-O{h#pRLsO&k$ z%i8ZqsD1Cu_QRNX%-V}_80Rx^+VWMxE+%c}F2YxG+=B={zfqt6!%p5#o2KfuT6dxo z6X!_39(@-rRMa#7YcbneukY7&n7$wrYan)!)%&4A`P*;j@xTZP>L$CMTAf+iX6t$P z)lY)Zyw^snT9I8gP38u&!bLy2T(r{?eMp^WnsTM)4zc;9vWN6?=YwV!09|9oqjPEb zJtM((AFfUHyU)T*+Z0Vu+7BB}Nc z4w9;8_nt^D#D;{m@pDL%^UdnKT%HdliKPSy>$4y;M~-eYNnIuMeIMBiS=I=%(NS{= z9%;KPB~Xs%!RPIid*3wRns4}=c1Qh2r9#T;;R83xdABg#Fo`e`{$_`9k5m|?t6x-^ z<_c5eBJLGF-&LAJFDU}>%Zl-~3jfpDWVCWb5tn*u;#J$){ejc>9Z`9XU)y|Z6 z9KJzzCiU|12fzNrg5t4V+^hYRH(!|-x~ys-Ba>46vMu~*QhsfPsH#7enfiz1agDSD zF4vC_kQ;)Y>8L=UyoKVXK27cEx4jz1E*8or`zH9hkK7tY5QXoB#1hjC#};`W^IF9E z_t+1Hz6&G*y0ozrXPtb?G&AnaN(DVT3yHMUjLvy>N-tkc#t()$T)oUyzR`C*EpZ#GX9A)~#6MKwow^qlGFhbs^N>I(7E>OB`Dqk!F=X zp)6%yVTMJ35bgD6vJ2Mv+~+&{hZ zXh~~Xbfoy_jmsB)0~7Z&;UkryFe9Sc1rjiw-rHs8`q_(w=SPmMxRY+L+s8)CUxL&z zimWNTrJI@Z`t_pd?Qm<0)gs=95Jc>}MVKYwk|YU>KRNB>0e_GD7W$dA&lf4`-mrGa z5rpsx^ATHdxIjKA#3X|C#+Q~VpI^n=ynN?G%F0~)Ol#>I32)t<33KzW4M51@lkX2p z3H&B39j47ve6Uv>yaJW5V}TwRbQj*8EWdR7E%MKW&%u(ZsmGy~PLX4~j+PX{ERy^z z(o{N4dqt~NgAh=N6Yiwg$hiI7ZbFHTqGKnz=S~H-Jt!Y z6}7te`~ZeP-L?nC0fl=a=-q7B-q5I>>YyMhM|I-wou*ISaal5%59Y1j#oUSClDn)b zr=UGIsb5t6J_THd4sjZ#gNPTUZp@rC`aE{_rTLiKB>V7wN?p$Vo=?q70fOR&D%_Jp zb_4qp?V|^2JcL*Fo;0OuzHxu>Nf;ZE-S2y7bR6-H&Hfy|dh8Z@50Vb0_m@7h?@rls zQ61xaQ8}v{;;QFX`#M0Emj>@7IzjBz-bg>EfdpRTO2|QEI1y?`*WanO4F@OBKIQl zVZgAgsv4Fz|>_c zX0&R~$&>4=8%l^HTd=9H7w%?#$KL8W^tT=0_}Ayq$ZIN zJeG}Lp@SkP(?||Gb}GpEr*>MBAtUyvrWRYyf@VPvCp6$O8ML$M)A$$3%`@_^v zM%wLN`b4IrFZXZQ<1aLKRU&-2O}k<9C&BXWkBRQzP9H}EYo}%C28{(Z1EL&Xnl^jm z0kn;gf{EVgufT#A_yO_0E7x6|G8++T=-`lqJ6Xd(5OXWB2bw= z4TsmQ`r}Lfx|`J{W2$7Jw=VLYR_P1W)rGO@85+r?V50BJ@_|BZAz8+zQF|JBWT`EO zI2`%K=QIs!?r0%iHLM;{rX4{VLV&&V$<@n3ocven-gPH{M1%^(H)YVoZ_z`v06_NDEEDHZF z8B7t}gI_R!#SfvOy5?bJRM5@y?(Mz)K(E$np$yP@%VFiHxGnbMLN&e3NMKtPS^36r z`H@yE#?VZdSW`1FrqD!4w8ZZau0bg(UML-Uc;V-0C=|E&vFG%;x6U=WcV#0Ul^Tyu zNtug%J>7zIZsivovEf;IUP!-a- z7FoGCsYu_ifRMbN_i7kLTPe8{NNd?~5Zz8{#bFw^HPOwFqcp{^jbz85oS&&wh$_7k zzbd;l`d@eU7X&b5xWQtvBOOPn+sPmC%c~=)g@EsE)?#*WZTL7nF|ncBjKU z9y$-V$q0)+{erGY69|)zI?7cHOTNEgKx^Wmn=O%|=cZybdgie{t9pT#-aSA2t>hDw zIiH9Rup~595WUsC@gS)VA2}5Bxr?GYj>105vZkmRWdu)#4Y5B6?PGLR-TN(#j(HDU5lqVxHJHXz8uKr+vJackU0a zj`r$tnhb>xSyMA3%;gXkyx*}&`?uKXW>33R1gGv>;1X-TFhJIfU&TZy{NVZ91b^~j z?WMyfESJV}^JU#eXXhVTh=WVDqxDg_Vp~C=Gc9|GOk4|UO4KQmQ}L3XvAKZd!1`y{ zA9q!*FrS{LYgz8T47d{*d8HQqd`^sM;!$WBEhdYaWEISN2tx1~AK{+I+}q&^5=PLF zo5T|ewI!4s%2_t4#1ToThV-4>SO_@gP*OKeTfU8uw4pySq(Z#OWBSc?U$%$EJ^wED zf~!9>CrBRL#Wmv?!3eqV{4rSayFzmAEFM0sPM`{)oPTY9@?L)% zJ3QMpQPar%1;qRs}UNGbJq48 z?wO#*$8}%&di6sn;2|Tt!&*AwZ0*4xj)D^ha(p+hY%3oTUtG0ycnD{I2Fh;uqe7|A zVWYV8t}a?fL`hSH?hxjIX>19$?%wAvv89W znwa<1-O-VpMy7A#>!z{!SvZJ*?Ov88{`9b2X~bh8QD93>`I{~^NX72O}%nqn9sVzcXuiWD@)zLOa$zm+F`n^)cS zCgMgNmmJ?Ig?kv!z=!CMMfO20fs2ION3ZR~RFm)wJVbgL8@|(L2}I31ySAzDLoij! zv1VCWU-CRxvg=kFy4&)l8+d}CFmyM4eq+b8na2H?ITwuqZvZF5|!5G%1PJJqaNF#{?}^4D-IO#yO6N#gtU)uS8fg*`r3wq5C8!wyepGI z4VtalPw$#VyU_NTR!~WvGg5Ppjn2OGIU3(@C2A;4uDVpOB(4V@+i73d@9tg4ibnW- zI6gdT*i4ZEYSH%d$Ay`NAGebV&fsXWV<+EZ<1>&H!*{#^hPX&)fC@Wtanck`57NW; zFgrBrIRelJQ@NUqklSB%LF~!K-Cl*uU9Rl;E-juKjOZ#^7{86b0VYpHSZ9vbWiKDQ z(;dxYAc9|2chfXn^1GU)HHu~T2%qt`&u$}W$3xIDC0z;)!mO;+(1E^)!ViZ2^S(ch z-dl*Lq=@wV3S^jI|97YffO(fo+GIP@f~K4YE^1Y4je6qJ2v@CvW6;Z_am{6hYVUimJTe3&z8+Dfk_DgHROV5R&?rb_G01<~+OMq| z*Ne?y0*@jt`_wR+W+mpWXl`*HnZ4+&r91YTpyAP3qGJ0eYsH3ME=ZFwLluR~2ha6Y zAQhry$D$9_P<96Dwe0}8fK1axBp$n_a}JKeCIM-NJ;J;dgeQ)NfFXGle}`xnbL#=3 zvF!&5^|jIxT%J_VrmVkE;pj}1dGs_^M&v1=OOIBWXOSsKVr}TyeQxDVWZLy(KsI?K z4vrF{PQq*=;ucS^!9k4GCY}K>S5aRhnG;!78!oaB#zV(*?cwqPK6B!YK^*YymHsE^3&GegU+3z`)>(Cy3Z5~dwr1SoNc zS`WgmLlXyt#vOn3WE+r?|FUK*>cHF%H*nyU0B>i|g35;WAVepAdb_y$bbz;S!!SP4 zA$aEXa){$NY)ioxdv%h}n#?VZUHw+R^y;&Cul0#p_`xW6HZU}O! zsw~B}jct4f<{w%0uO~6-nQF1-(0&>CiJ`r2qWUUoP(XttO)|+PUPgP@9<7ap55^NlTS|#vylX?Y2q|j}Tu^yA9cE^Z9 z`mZeo@VGm30L6JHtR8E-9}pBu4RiU`J=Mty%JiFnFvq7X8w>pJj4oFC*?eBAc1asZm&z@7VKSgY&`ANhW@dD~$U} z=a-4o;M=j+ex}XzLU2ojYPb1_juJdCVosa+S9;ofh_Aj$`dYSmk+)K;`8d2I_c&kq73 z2QGr7M`PTN=S7WdCNdFs-F8)@4#i0%XFOj?OT$4eD?o=*FewY`HLLzCiZ z_jtSZx0|7p^C8H-cNW@4*l*g5Y9zBu%0!;!58(I)0-VcwKH2IB&$E0m^da)ESnzL* zMcM)`EObqT4=P-dVf;$EtWN6VYc4hP=XWYB!4=q@vUL8DlA{j2I(2B;!b$9hEdD||ri%?9$b-$BtIE2on ze7u6m`Nt*~wB0ArqkYy~bs^O)EjW5t2b=+Z#N!zt_R9)z=B#<^a6#DnY-`P4sxMUV&HCvHgE&%NY@Ik^`Jn z%rNiE+M_2muPgsdG_m=cj_DT@KdWHgsA$a7u(5_PMjD)2Dfo zI!}qT%Gj_`+abf_fq6~EbgN`^14(_4RDa5w>Aa?alvjKXWNT?j&?SkO$H7KeAt~jb z3#AoFRRf$r-I0fbn0MYz zRIA~SC<42g#D%X>bgC}(uRX$J;yjlTF5Aa?6m(Qufe41%#G8PY+H^sePu60h*S9Tp zjIBmtf0JF``M#3@A)be>gNQ)_*AXQyGpuw=BLXDoysJGBg#86+Me*FI!%8pbNf67{ zvCFsdAh&WHmLi|f)Ts2j?vwDF)dSfGl-AI6Mdtpye^(ZknVgZiFsrXR+g6~&V4)0H zk?WrfYM2!=NnP=u=R}gWU|2&iub>7w(Y>%fW!0ov$!M16^Wfckrd5N@5qEZpO3Y>r z>R7hS;M+4z##ZkCR=4iR(arG1n(M8A9F6@SE+5(O4{u)q(>Hs8M1-vW5iC%s{FN1$ zcp|vL8yFT+yfX4nTN&aC#fyx}{_N+Vc>B-)|J}!tTKw0>zgs!d{vVPn{71hM^G}7D zVd)|NDVATD{-`Kei2mJa^Z!Z>{%^dlul|6$PY4+`wBF3)9X`2Z*{|kDax}gOlr%RO zC8}V5_rFOnWaHp40z}pm)P|Wb*?(F<2QlK(`2qW{1Vi>;Sq4flFpPd15)1!HMbQ{4 zC+mGjG%~~ybQ$M5A5|;5{zw!Bf^+?7>|##>mHW}oM2!UYG}nuMh>G7j z-$uB}0@=H_!UkOo-Gr-VO86xLuv4rc-K!pIHZEtT8vl!yB8ZQ$+ex*Tu>se9N7d!p zRNR6%GWm4OwmMh0>Kr62P(l6=I`KMc(%kh6&~ z-iTr%>MwSZkLN+pCTP1pMMMHqD>2(V`Zc`=W}@I}B`=wl5iM1tGXGoY;tn5{dtT8M#%JB9vB!^Vq<-3L*>mvK(%! zzojXf!Fwh&Wof+hXU`gL?7v8;F2LFL;z3J`WBOH{x?FmeV7NT2@JNNn*e&T*6STf^>1oAhUxg&_U)xqDi5p6DBS^{nn zpy~CxY(X}=#ji)F^V!Xcf3!XXYeP}Eq&`l3^M$sw~o6?K>;=07M(%5?}hYULyS!b0_z*eSgR43)ltY6cZR8%P~hgJ15pcGZC z|6M7YFB&GUw;q8yH$G!7SFNGYlfsB1iUgmEOCPUfsFl~=^!minJ+iuIbPqh_(7W~BrR=m6NTe!}xjOBN8o7$X*C+Nia*$f`%pby+n$AIZ#N7VI$|XzjuQGZs zSEj-;AiTnJcPG=4mSI}WvhJjYzDm*syVQlfGBe7s`6=>v?*%D6Q~luPb1a&w_|(*H zZVvl%8lVQCyb0bqB?l^OL@R#2Q(L?#Om6=!%}^7w1DTEGz&1Xc97dMGUctc?(pXMQ zxzBP5G$&?k>)((9OHsrQaTFsg?eUIVGitg-DfzUw#~GXeFwnt3P_S_`t6GSufuZL3 za%9YmJ4jjt&71|FAGDkT1`ovj8?biuT%UU7vq&X0InMB*%m`g=Y)bcL}wt_us3Wn)_ zon0&JQwEfgFaVTyxO$&E3Y*w_eVa%QGZcn(MZA84lWd_Q@%>HIOVPa{!a=fy$vNux z=QG=G-*)N?4C&&6^zD)CvOQ9{BQai)oozPexE4({(3H>K4Zue&Y9m!8s=(`Ye*SV5S3@4eNkaX*O=r9ljX z6_J@Z3{hKj9aL|p+YqHgGGKpwZtY6|^1)`5PFN-fV^mR+X!KqWKf0thTFH zgjYQnk#4L!b&*%Q$l~m-tN-cNToDN}`PzoAz?;ik&A<<7`u)U`Zix>GUzbrVFRsSh z*tcy~@O_ZuMWr=SIY>B9)STChTd2cS=HTBg$B&AaVE4S|Xd2&(ze~y#PsU6Qyk8=# z{-b+=wl3s_%w(1qKO(emTHncn1%Xe2DDGDzP!EM~SDinyw!2ur%HaIs;nn-gjbH?j z!v|?I6!`;3Q9nmRYGFD7WpH#1xh%NiEUKQP+25rMzWAEgUhsskSiwxzjWek)3^nPh zy)oacL2&0)Y(8VfoJS>fREz)8U9+h8vL}K0Je1S_1pSGZQ(H(CPq+IbimXPgVI4Bs zJLsTk^LW+Xn}0CF+vmN&fyVgd+gc)@+Y zD$tst4m^3U2^K6LT|Adl%Dq1UD4BbN5sbd9T9_=8at08d>21XVOEKwL%Uz6LG5KJ3 zf!oXcU&5{)bdnUuQ=0WA_Ryv!b2bC$i$Y&!N^0_D4=?iGU_VEfu6ZRdZ>jLl8XC?CN!=66 zRY%JxlRQEod@2^+g>4;Qn|Uqd4cYbkvKbz!K`1kBtt(b^;`wl6F7HDLb_p6`U*XcV94X|;FCxZZM5 zyn5qW#B0gDB$7e(xV^`$1#B=L@=3R-jSb4G6-GcwY=7$U6$GVcPS<>Y@FK5;oW7`> zpF`+hP&eXtW57$ezF%|Yws6(k2qtbBY+p%(gcXW@2RMt7zbduPir+7H}-P1V_yZD)wF7bV2@cb&Dgw%Z9oUw zX~29&P5dlAyl@j zK#*w|TT+`A(-=SFSiROvPze@=>I!0SN6yZeeuNjt+LeBAiH~>*m0p|s6H@IbK>PAu z&Boev)IBYjjW+h4Xs#=gBhVsh7Gtw<5eIqaH*Y>*#md4nsE}KUhDJO(2=O9mn%-DmEY5@487?Erk-ftW&xO5ZvdYodF>BKb zW4~ohF@XwdnJzib->VCMAYbowch$=v4dbxzZxW^YAH|$^R1@pE#|Ia(&@q#;=>{~k z*tGP=K9soYrZ2MyVoy5uIyN7*kuNVQ#Ii{T^boJ19tV?q%6_`z7}2on>EwA$rBM`` zBoNP(o)g~^QISVdcS8!*!@JH71t?SCko4d@+S@Yc7!`eAHmP$mSbDd|9NNICqfxJft-=?Ai2bMo&)UJ4PG>a1+yveps7AEZU`8gWPHVlmUJ_ z2RgnFdeB6H69;J>-k(!wZnoI9qkN@+5y(RS$LR)e#a|WoMR#vr^FnL9h##*ywxXYa%FhuPP=cZNfY54Z14>4_hwpZ+zRHVR?9o zP~#ViXcUhtJmJaj(J2b&ix&6*%+g&=dBOXo6hK4$=(Q_nmCa=Vt@{Qhl@>Yc8Wc*3 zu8`YE;93G@-|V1uBPopeymOUK_V$7Y1#Z@H3z?CUoISW5tt$`Bn~lH6)0vZB`A{lXXYTSI|7K&vWOy7$8Nyath0eu>NO+2b;OYE0QKm*Thmz+@j@I*10!ZJotliemGiEBCWRQZ5yM9s z9jiEriSTX*o+D3WN5;?U<$otxlU?(d0&=OOh`I`c&avtz{Z1)~&6-3bX_hg{?&GXW zjr}72@_mA5QCs9v1jRE{Apck?z4K3OX{9RjFjF&Oca;598hpfb&L$9Tmb+8iK$8w} znB>Y!)?EeTiBy(!^mTtAi%&}MYuc6L0@UX|pDr`h#!sYtC-O=1%92gYJmbgWH%&k# zBKen7_oELG`khZpd_vRf7<{qwHAAA<$T$ui6KT+1Q9*{g;$Wy zJtY+-DBBG&&`yYl*n&%|_w^Q!qH|pxQiaBieaGGxlzY`~eVmlsixyVETOxXJ0&%9c zXWee*?vaeYdlF)v_AH@Ge0B;LTBm-MA_+h#QrzWbjeySj^h9`=tse=9uMCOeM-xf| z&33HQRN#7h!|>zy2n)^<~t`>0{Bf0_|(;jot%u$;F7ldm<&!LUQ5aIzi3kp|XbtY&Twq_LRb zTNyX9;XNHo>bqU6@&SwM9_7@VKL3q&?OS?Bk=$2$nlpAFh&!F*h!&rgKRWHM!nNG1oBaxng z!*c`?q2PMgmvv}%`D95H60)L3!SDPIp{8P$3KPnbaBchR0O7IJvQTdGX@$AAu4vpR zM{usa)jD?4E5DmQ@=8Q;XADa>ojI@g6#7b%g_^aC^4-Gn>{i}p`*M7>0_kK9HHU~5 z1`8%xdsK(g!W-@=jD$^X(zfRWgg!nUj;-~m;mNiLq#gRJ{Jy;IdWta0nhqgt$06th zLccq_aP^a~;=oE}Z|kC8u>J7GOVKvVeKegpyhu3tQ>m{U+JxPnwxMx?ikmq5`YbI- z@J|)hgDDR{hD{H10I)1I%9EE_p53r#tF}^jS=e zT57mFJ#e)njz8A{(`}pRV%Z&EwE97GCUPWhYj4qOY*eJ)gokf5h0v{sn0MwFcFe*S zQB}iT+GaeOribGL>E-dQGGuoZjFNMi{1I=>r(ZEh34t*l*XTS*!Oz)fIK8y!mldHq zSWNMVImjYXna>R)Qcs8AbLM;Pw)oN-G|r_N=KZQMI=y zUa<{lO&uoKXt3gpXjHfRplmyxFNaekhD;*EIZE556cxUcnJC++cK*3CVoGaG%p3ab7FB&`5ud z#abodTc}gKCM)hpnfpFQ6lgo+4|WC!x10G}cVjH@1R|GBXVWWJB};&D z4;UAAis1aY`Z3LR(uCr5M^5Ysl@;q9Xvt~O`X?&Tv=Is-W3Qaer&MB1b7}FgC57V< z=eGc8&4fIP4PMeNuXj(ht2{Y7Nf6auo*bewb=YOW@%#aqRP4#>hA^>YX|%)6i?UfKcf7eB>2#hzM&Z%uD!F)2|A>vG>+uud}c9l@6#*}x3vwYTJSzrc_o zg7Tckm1#9@aSB|9PzHeOq^vaNt=zv-fvp3vcR0dW+iY7-wQq92S(vzp5_mMs*~H(^ z0nwT5A(UvDg0V#U>zX=3qRRbM+Gq~e#h(k=(G}n8-^YQ*(Kx_NvBKg;H zx-|stFNOoY167`)9cRpIrHO@})t8vXITPN5^>40Nihi4G8Dwc(D)XEfi8>Tr)XuO%<4Kd{82Va6*UiYDrNdC8|!^tn;Bo5oR4`l zCqXhqXphBH7g8yOV1)c~NVI;H`BFNN6y*Ztfu*qQzUYdNb%(K}<)&~N08bh91lUJx z)7p~-K*y`T`l9=-64y2Z>>g0wtnfrAXFBG{2@*;WAji`ty=$Ko`X+p!rQCw3`J#4F z(v)Of{=vz2j%qL%!QyA#rv%)0xSExOt+3mMMew|Il?h?c0dlph7HJSi2@wat$qlap zO|fLmoC8zGcO<-|({trzDsi(UNEJVm2h+puZEUtx)UOzEw|otO`AZRF248sjSr57uoiK>PouBp9G6Q5zEm2Bs)#qeuQ@h#9H=*4q>Q%mXF>lt>)|oB)8q zb&R5Jh+fZBbVhk1!gHf<;DfxwR#kAQp>5k&AwTm#3`nT|UKyIb+M}Q?(gpv;Nk519 ziL@G?agTjeR;bQkjr@6o+;i2j_!oaEGg^#BI(9OW>R*FjjB4FqZWw<$5P`HW(BQ-+ z23B@EsY%NC%_Y|ehFZHvnxmH?xCg~-AmQUed}vEPG=;~mIW#LD@`nK%mqjH zcL`0!BM=|*XO{2g7ISVnM`9sXpO!zvvfq%Mis76tG_(tHq1)((2rsT~Ti}+84<%@A zUZ=p!_4yj}MK%14&zbmJAjT?g%@+(;Tt!}yFu$jiW&n<)nj}M%mi-yxT7hZh^D)wb zk}ah0PiLjHWF%O!Zkro;6g4?1hDci1o##Q74kYBV?kWtw*3@tkXnf+7;y^0PmgsVs z+pm6~&?=ybX++b;KM@}`&&U#)PCTbYQgEdB2LuOZ>tEdbJpy)NzPgA~SJ%|x*&w;~ zH46O^7jI1?e!<{Oc&p>NrYlq;R+(I>H^1mzm0!f&kkffl+uP!y{3ktuhE;zBx|JtSXMdb4ft0`o~}^~fLp{r&ZgqH0g5 znf*sQGM{2*cRA$?7yN2&M*(4-LDi}g5w6b^`kz9aXq3GBcy`--H8ZKof`x*u#bxB5 ztq+RN3UDOKjm;A-cni{XIZY}&9HH_Dwx+D7a+P&&*_ZpM-NlP7VQ@r}S9DX4lr^!j zHplRl1lOlkvV|e91@UDU98kU@KH}L4Oan0~&Yy59XC(Br!SZ7ZWx5lRI%wlYu_9RQXlyAz{^vc^_4RkBWaw-tyCAA4*xnEKy^Mwtv>VY zJEr0m{@wjvm+{+Wy_ZFQ@s>p2OR)|bK#R6yB)fY_VKT&(lseszo=ewRnPUz$HI3nP z5I1$XgWGhkGQn5ulhyF5az3n_Dw8De%Pm?CT$uI+-gT@kzpU{4o6}(fl;!RYUgY9a zXFk^qT*T=C)fvI~w;<^U%5di7JriaCO(g129kSm_>GcJa9&`e~OUNSyP&iQ7(vw1g zSvga(>F>2^W(BKH!N8rl@@<>Ic40ZN|4cGgj!DMfEB@~Z}O_y^u7hWQ~i+zdmkp6;K;giMnpSf z8{JvKuO)e=?t>WcYP5?V9{Q?k)OI7Tp(MjuI|@sPdtNkDpC$eF>TgW;F| z8Tg_u9RoznHt^#uyxv%S!fs_Z3`NDfGTnXn2*_69{Iw7~<4&{c2~Qfxpq<+dfCNCK z?vGGfrtcE}wkTRqyRV^P+9C4j8pd_73g{IIqk4OL=e-p6k0WJd1mm87^pq_V@Nde> znEs4xKI}a@b(lkhv&CTL%Uoepv8iZ5L4m^Fj|3Wr6le3na