diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 88aaf34..2d5dddd 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -8,4 +8,7 @@ updates: - package-ecosystem: "pip" # See documentation for possible values directory: "/" # Location of package manifests schedule: - interval: "weekly" \ No newline at end of file + interval: "weekly" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index df2abbc..b798b19 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ exclude: | repos: - repo: https://github.com/psf/black - rev: 24.2.0 + rev: 24.3.0 hooks: - id: black # args: ["--check"] @@ -31,6 +31,6 @@ repos: - id: flake8 - repo: https://github.com/RobertCraigie/pyright-python - rev: v1.1.354 + rev: v1.1.355 hooks: - id: pyright diff --git a/apps/cap_feed/data_injector/feed.py b/apps/cap_feed/data_injector/feed.py new file mode 100644 index 0000000..7ef3e7f --- /dev/null +++ b/apps/cap_feed/data_injector/feed.py @@ -0,0 +1,47 @@ +import json +import os + +from apps.cap_feed.models import Country, Feed, LanguageInfo + +module_dir = os.path.dirname(__file__) # get current directory + + +# inject feed configurations if not already present +def inject_feeds(): + file_path = os.path.join( + os.path.dirname(module_dir), + 'feeds.json', + ) + with open(file_path, encoding='utf-8') as file: + feeds = json.load(file) + print('Injecting feeds...') + unique_countries = set() + feed_counter = 0 + for feed_entry in feeds: + try: + feed = Feed() + feed.url = feed_entry['capAlertFeed'] + feed.country = Country.objects.get(iso3=feed_entry['iso3']) + feed_counter += 1 + unique_countries.add(feed_entry['iso3']) + if Feed.objects.filter(url=feed.url).first(): + continue + feed.format = feed_entry['format'] + feed.polling_interval = 60 + feed.enable_polling = True + feed.enable_rebroadcast = True + feed.official = True + feed.save() + + language_info = LanguageInfo() + language_info.feed = feed + language_info.name = feed_entry['name'] + language_info.language = feed_entry['language'] + language_info.logo = feed_entry['picUrl'] + language_info.save() + + except Exception as e: + print(feed_entry['name']) + print(f'Error injecting feed: {e}') + + print(f'Injected {feed_counter} feeds for {len(unique_countries)} unique countries') diff --git a/apps/cap_feed/data_injector.py b/apps/cap_feed/data_injector/geo.py similarity index 76% rename from apps/cap_feed/data_injector.py rename to apps/cap_feed/data_injector/geo.py index da2f5a8..bed8cd5 100644 --- a/apps/cap_feed/data_injector.py +++ b/apps/cap_feed/data_injector/geo.py @@ -3,7 +3,7 @@ import requests -from .models import Admin1, Continent, Country, Feed, LanguageInfo, Region +from apps.cap_feed.models import Admin1, Continent, Country, Region module_dir = os.path.dirname(__file__) # get current directory @@ -43,7 +43,7 @@ def process_continents(): continent_data = json.loads(response.content) process_continents() else: - file_path = os.path.join(module_dir, 'geographical/continents.json') + file_path = os.path.join(os.path.dirname(module_dir), 'geographical/continents.json') with open(file_path) as file: continent_data = json.load(file) process_continents() @@ -68,7 +68,7 @@ def process_regions(): region_data = json.loads(response.content) process_regions() else: - file_path = os.path.join(module_dir, 'geographical/ifrc-regions.json') + file_path = os.path.join(os.path.dirname(module_dir), 'geographical/ifrc-regions.json') with open(file_path) as file: region_data = json.load(file) process_regions() @@ -125,7 +125,7 @@ def process_countries_opendatasoft(): region_data = json.loads(response.content) process_regions() else: - file_path = os.path.join(module_dir, 'geographical/ifrc-regions.json') + file_path = os.path.join(os.path.dirname(module_dir), 'geographical/ifrc-regions.json') with open(file_path) as file: region_data = json.load(file) process_regions() @@ -137,7 +137,7 @@ def process_countries_opendatasoft(): country_data = json.loads(response.content) process_countries_ifrc() else: - file_path = os.path.join(module_dir, 'geographical/ifrc-countries-and-territories.json') + file_path = os.path.join(os.path.dirname(module_dir), 'geographical/ifrc-countries-and-territories.json') with open(file_path) as file: country_data = json.load(file) process_countries_ifrc() @@ -149,7 +149,7 @@ def process_countries_opendatasoft(): country_data = json.loads(response.content) process_countries_opendatasoft() else: - file_path = os.path.join(module_dir, 'geographical/opendatasoft-countries-and-territories.geojson') + file_path = os.path.join(os.path.dirname(module_dir), 'geographical/opendatasoft-countries-and-territories.geojson') with open(file_path) as file: country_data = json.load(file) process_countries_opendatasoft() @@ -184,45 +184,7 @@ def process_admin1s(): admin1_data = json.loads(response.content) process_admin1s() else: - file_path = os.path.join(module_dir, 'geographical/geoBoundariesCGAZ_ADM1.geojson') + file_path = os.path.join(os.path.dirname(module_dir), 'geographical/geoBoundariesCGAZ_ADM1.geojson') with open(file_path, encoding='utf-8') as f: admin1_data = json.load(f) process_admin1s() - - -# inject feed configurations if not already present -def inject_feeds(): - file_path = os.path.join(module_dir, 'feeds.json') - with open(file_path, encoding='utf-8') as file: - feeds = json.load(file) - print('Injecting feeds...') - unique_countries = set() - feed_counter = 0 - for feed_entry in feeds: - try: - feed = Feed() - feed.url = feed_entry['capAlertFeed'] - feed.country = Country.objects.get(iso3=feed_entry['iso3']) - feed_counter += 1 - unique_countries.add(feed_entry['iso3']) - if Feed.objects.filter(url=feed.url).first(): - continue - feed.format = feed_entry['format'] - feed.polling_interval = 60 - feed.enable_polling = True - feed.enable_rebroadcast = True - feed.official = True - feed.save() - - language_info = LanguageInfo() - language_info.feed = feed - language_info.name = feed_entry['name'] - language_info.language = feed_entry['language'] - language_info.logo = feed_entry['picUrl'] - language_info.save() - - except Exception as e: - print(feed_entry['name']) - print(f'Error injecting feed: {e}') - - print(f'Injected {feed_counter} feeds for {len(unique_countries)} unique countries') diff --git a/apps/cap_feed/dataloaders.py b/apps/cap_feed/dataloaders.py index 15ad84c..457554a 100644 --- a/apps/cap_feed/dataloaders.py +++ b/apps/cap_feed/dataloaders.py @@ -9,6 +9,7 @@ from .models import ( Admin1, + Alert, AlertAdmin1, AlertInfo, AlertInfoArea, @@ -89,6 +90,22 @@ def load_admin1s_by_country(keys: list[int]) -> list[list['Admin1Type']]: return [_map[key] for key in keys] +def load_info_by_alert(keys: list[int]) -> list[typing.Union['AlertInfoType', None]]: + qs = ( + AlertInfo.objects.filter(alert__in=keys) + # TODO: Is this order good enough? + .order_by('alert_id', 'id') + .distinct('alert_id') + .all() + ) + + _map: dict[int, 'AlertInfoType'] = { # type: ignore[reportGeneralTypeIssues] + alert_info.alert_id: alert_info for alert_info in qs + } + + return [_map.get(key) for key in keys] + + def load_infos_by_alert(keys: list[int]) -> list[list['AlertInfoType']]: qs = AlertInfo.objects.filter(alert__in=keys).all() @@ -159,6 +176,41 @@ def load_language_info_by_feed(keys: list[int]) -> list[list['LanguageInfoType'] return [_map[key] for key in keys] +def load_alert_count_by_country(keys: list[int]) -> list[int]: + qs = ( + Alert.get_queryset() + .filter(country__in=keys) + .order_by() + .values('country_id') + .annotate( + count=models.Count('id'), + ) + .values_list('country_id', 'count') + ) + + _map = {country_id: count for country_id, count in qs} + + return [_map.get(key, 0) for key in keys] + + +def load_alert_count_by_admin1(keys: list[int]) -> list[int]: + qs = ( + Alert.objects + # TODO: Add is_expired=False filter + .filter(admin1s__in=keys) + .order_by() + .values('admin1s') + .annotate( + count=models.Count('id'), + ) + .values_list('admin1s', 'count') + ) + + _map = {admin1_id: count for admin1_id, count in qs} + + return [_map.get(key, 0) for key in keys] + + class CapFeedDataloader: @cached_property @@ -185,6 +237,10 @@ def load_admin1s_by_alert(self): def load_admin1s_by_country(self): return DataLoader(load_fn=sync_to_async(load_admin1s_by_country)) + @cached_property + def load_info_by_alert(self): + return DataLoader(load_fn=sync_to_async(load_info_by_alert)) + @cached_property def load_infos_by_alert(self): return DataLoader(load_fn=sync_to_async(load_infos_by_alert)) @@ -212,3 +268,11 @@ def load_info_area_geocodes_by_info_area(self): @cached_property def load_language_info_by_feed(self): return DataLoader(load_fn=sync_to_async(load_language_info_by_feed)) + + @cached_property + def load_alert_count_by_country(self): + return DataLoader(load_fn=sync_to_async(load_alert_count_by_country)) + + @cached_property + def load_alert_count_by_admin1(self): + return DataLoader(load_fn=sync_to_async(load_alert_count_by_admin1)) diff --git a/apps/cap_feed/filters.py b/apps/cap_feed/filters.py index db7d58f..d04b738 100644 --- a/apps/cap_feed/filters.py +++ b/apps/cap_feed/filters.py @@ -1,15 +1,86 @@ import strawberry import strawberry_django +from django.contrib.postgres.aggregates.general import ArrayAgg +from django.db import models +from .enums import ( + AlertInfoCategoryEnum, + AlertInfoCertaintyEnum, + AlertInfoSeverityEnum, + AlertInfoUrgencyEnum, +) from .models import Admin1, Alert, AlertInfo, Country, Feed, Region @strawberry_django.filters.filter(Alert, lookups=True) class AlertFilter: id: strawberry.auto - url: strawberry.auto - sender: strawberry.auto - admin1s: strawberry.auto + country: strawberry.auto + sent: strawberry.auto + + @strawberry_django.filter_field + def region( + self, + queryset: models.QuerySet, + value: strawberry.ID, + prefix: str, + ) -> tuple[models.QuerySet, models.Q]: + return queryset, models.Q(**{f"{prefix}country__region": value}) + + @strawberry_django.filter_field + def admin1( + self, + queryset: models.QuerySet, + value: strawberry.ID, + prefix: str, + ) -> tuple[models.QuerySet, models.Q]: + return queryset, models.Q(**{f"{prefix}admin1s": value}) + + def _info_enum_fields(self, field, queryset, value, prefix) -> tuple[models.QuerySet, models.Q]: + alias_field = f"_infos_{field}_list" + queryset = queryset.alias( + **{ + # NOTE: To avoid duplicate alerts when joining infos + alias_field: ArrayAgg(f"{prefix}infos__{field}"), + } + ) + return queryset, models.Q(**{f"{prefix}{alias_field}__overlap": value}) + + @strawberry_django.filter_field + def urgency( + self, + queryset: models.QuerySet, + value: list[AlertInfoUrgencyEnum], # type: ignore[reportInvalidTypeForm] + prefix: str, + ) -> tuple[models.QuerySet, models.Q]: + return self._info_enum_fields("urgency", queryset, value, prefix) + + @strawberry_django.filter_field + def severity( + self, + queryset: models.QuerySet, + value: list[AlertInfoSeverityEnum], # type: ignore[reportInvalidTypeForm] + prefix: str, + ) -> tuple[models.QuerySet, models.Q]: + return self._info_enum_fields("severity", queryset, value, prefix) + + @strawberry_django.filter_field + def certainty( + self, + queryset: models.QuerySet, + value: list[AlertInfoCertaintyEnum], # type: ignore[reportInvalidTypeForm] + prefix: str, + ) -> tuple[models.QuerySet, models.Q]: + return self._info_enum_fields("certainty", queryset, value, prefix) + + @strawberry_django.filter_field + def category( + self, + queryset: models.QuerySet, + value: list[AlertInfoCategoryEnum], # type: ignore[reportInvalidTypeForm] + prefix: str, + ) -> tuple[models.QuerySet, models.Q]: + return self._info_enum_fields("category", queryset, value, prefix) @strawberry_django.filters.filter(AlertInfo, lookups=True) @@ -31,6 +102,17 @@ class CountryFilter: class Admin1Filter: id: strawberry.auto + @strawberry_django.filter_field + def unknown( + self, + queryset: models.QuerySet, + value: bool, + prefix: str, + ) -> tuple[models.QuerySet, models.Q]: + if value: + return queryset, models.Q(**{f"{prefix}id__lt": 0}) + return queryset, models.Q(**{f"{prefix}id__gte": 0}) + @strawberry_django.filters.filter(Region, lookups=True) class RegionFilter: diff --git a/apps/cap_feed/models.py b/apps/cap_feed/models.py index c910111..a8c6a13 100644 --- a/apps/cap_feed/models.py +++ b/apps/cap_feed/models.py @@ -240,6 +240,11 @@ def __init__(self, *args, **kwargs): def __str__(self): return self.url + @classmethod + def get_queryset(cls) -> models.QuerySet: + # TODO: Add is_expired=False filter + return cls.objects.all() + def info_has_been_added(self): self.__all_info_added = True diff --git a/apps/cap_feed/queries.py b/apps/cap_feed/queries.py index e6e6090..f827195 100644 --- a/apps/cap_feed/queries.py +++ b/apps/cap_feed/queries.py @@ -1,5 +1,8 @@ import strawberry import strawberry_django +from django.db import models +from django.db.models.functions import Coalesce +from strawberry_django.filters import apply as apply_filters from main.graphql.context import Info from utils.strawberry.paginations import CountList, pagination_field @@ -73,6 +76,35 @@ class PublicQuery: async def region(self, info: Info, pk: strawberry.ID) -> RegionType | None: return await RegionType.get_queryset(None, None, info).filter(pk=pk).afirst() + @strawberry_django.field + async def all_countries( + self, + info: Info, + alert_filters: AlertFilter | None = None, + include_empty_filtered_alert_count: bool = False, + ) -> list[CountryType]: + queryset = CountryType.get_queryset(None, None, info) + if alert_filters: + alert_queryset = AlertType.get_queryset(None, None, info) + alert_queryset = apply_filters(alert_filters, alert_queryset, info, None) + queryset = queryset.annotate( + filtered_alert_count=Coalesce( + models.Subquery( + alert_queryset.filter(country=models.OuterRef('id')) + .order_by() + .values('country_id') + .annotate(count=models.Count('id', distinct=True)) + .values('count')[:1], + output_field=models.IntegerField(), + ), + 0, + ), + ).order_by('-filtered_alert_count') + if not include_empty_filtered_alert_count: + queryset = queryset.exclude(filtered_alert_count=0) + + return [country async for country in queryset.all()] + @strawberry_django.field async def country(self, info: Info, pk: strawberry.ID) -> CountryType | None: return await CountryType.get_queryset(None, None, info).filter(pk=pk).afirst() diff --git a/apps/cap_feed/tasks.py b/apps/cap_feed/tasks.py index e8776c0..755b7c4 100644 --- a/apps/cap_feed/tasks.py +++ b/apps/cap_feed/tasks.py @@ -3,7 +3,8 @@ from celery import shared_task from django.utils import timezone -from . import data_injector as di +from .data_injector.feed import inject_feeds +from .data_injector.geo import inject_geographical_data from .formats import format_handler as fh from .models import Alert, AlertInfo, Feed, ProcessedAlert @@ -40,6 +41,6 @@ def remove_expired_alert_records(): @shared_task def inject_data(): - di.inject_geographical_data() - di.inject_feeds() + inject_geographical_data() + inject_feeds() return "injected data" diff --git a/apps/cap_feed/types.py b/apps/cap_feed/types.py index 2d5d439..65db1ce 100644 --- a/apps/cap_feed/types.py +++ b/apps/cap_feed/types.py @@ -3,12 +3,15 @@ import strawberry import strawberry_django from django.db import models +from django.db.models.functions import Coalesce +from strawberry_django.filters import apply as apply_filters from main.graphql.context import Info from utils.common import get_queryset_for_model from utils.strawberry.enums import enum_display_field, enum_field from utils.strawberry.types import string_field +from .filters import AlertFilter from .models import ( Admin1, Alert, @@ -67,6 +70,7 @@ class CountryType: @staticmethod def get_queryset(_, queryset: models.QuerySet | None, info: Info): + # TODO: defer polygon, multipolygon return get_queryset_for_model(Country, queryset) @strawberry.field @@ -79,16 +83,55 @@ async def continent(self, info: Info) -> ContinentType: # TODO: Create a separate admin1s_names @strawberry.field - async def admin1s(self, info: Info) -> list['Admin1Type']: + async def admin1s( + self, + info: Info, + alert_filters: AlertFilter | None = None, + include_empty_filtered_alert_count: bool = False, + ) -> list['Admin1Type']: + if alert_filters: + alert_queryset = AlertType.get_queryset(None, None, info) + alert_queryset = apply_filters(alert_filters, alert_queryset, info, None) + + queryset = ( + Admin1Type.get_queryset(None, None, info) + .filter(country=self) + .annotate( + filtered_alert_count=Coalesce( + models.Subquery( + alert_queryset.filter(admin1s=models.OuterRef('id')) + .order_by() + .values('admin1s') + .annotate(count=models.Count('id', distinct=True)) + .values('count')[:1], + output_field=models.IntegerField(), + ), + 0, + ), + ) + ).order_by('-filtered_alert_count') + if not include_empty_filtered_alert_count: + queryset = queryset.exclude(filtered_alert_count=0) + + return [admin1 async for admin1 in queryset.all()] + return await info.context.dl.cap_feed.load_admin1s_by_country.load(self.pk) + @strawberry.field + async def alert_count(self, info: Info) -> int: + return await info.context.dl.cap_feed.load_alert_count_by_country.load(self.pk) + + @strawberry.field + async def filtered_alert_count(self, _: Info) -> int | None: + return getattr(self, 'filtered_alert_count', None) + @strawberry_django.type(Admin1) class Admin1Type: id: strawberry.ID name = string_field(Admin1.name) - # TODO: use custom type + # TODO: use custom type (or use file?) polygon = string_field(Admin1.polygon) multipolygon = string_field(Admin1.multipolygon) min_latitude = string_field(Admin1.min_latitude) @@ -98,6 +141,7 @@ class Admin1Type: if typing.TYPE_CHECKING: country_id = Admin1.country_id + pk = Admin1.pk else: country_id: strawberry.ID @@ -105,10 +149,23 @@ class Admin1Type: def get_queryset(_, queryset: models.QuerySet | None, info: Info): return get_queryset_for_model(Admin1, queryset) + # TODO: Refactor to remove negative pk for Admin1 + @strawberry.field + async def is_unknown(self) -> bool: + return self.pk < 0 + @strawberry.field async def country(self, info: Info) -> CountryType: return await info.context.dl.cap_feed.load_country.load(self.country_id) + @strawberry.field + async def alert_count(self, info: Info) -> int: + return await info.context.dl.cap_feed.load_alert_count_by_admin1.load(self.pk) + + @strawberry.field + async def filtered_alert_count(self, _: Info) -> int | None: + return getattr(self, 'filtered_alert_count', None) + @strawberry_django.type(LanguageInfo) class LanguageInfoType: @@ -306,7 +363,7 @@ class AlertType: @staticmethod def get_queryset(_, queryset: models.QuerySet | None, info: Info): - return get_queryset_for_model(Alert, queryset) + return get_queryset_for_model(Alert, queryset, custom_model_method=Alert.get_queryset) # TODO: Create a separate country_name @strawberry.field @@ -322,6 +379,10 @@ async def feed(self, info: Info) -> FeedType: async def admin1s(self, info: Info) -> list[Admin1Type]: return await info.context.dl.cap_feed.load_admin1s_by_alert.load(self.pk) + @strawberry.field + async def info(self, info: Info) -> AlertInfoType | None: + return await info.context.dl.cap_feed.load_info_by_alert.load(self.pk) + # TODO: Need to check if we need pagination instead @strawberry.field async def infos(self, info: Info) -> list[AlertInfoType]: diff --git a/apps/subscription/models.py b/apps/subscription/models.py index 26cef53..c1e69ed 100644 --- a/apps/subscription/models.py +++ b/apps/subscription/models.py @@ -43,5 +43,10 @@ def save(self, *args, force_insert=False, force_update=False, **kwargs): cache.add("v" + str(self.id), True, timeout=None) subscription_mapper.apply_async(args=(self.pk,), queue='subscription_manager') - def delete(self, *args, force_insert=False, force_update=False) -> tuple[int, dict[str, int]]: + def delete( # type: ignore[reportIncompatibleMethodOverride] + self, + *args, + force_insert=False, + force_update=False, + ) -> tuple[int, dict[str, int]]: return super().delete(force_insert, force_update) diff --git a/docker-compose.yml b/docker-compose.yml index f5b3813..8d1916a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3.2' name: alert-hub # NOTE: Define COMPOSE_PROJECT_NAME in .env to use custom name x-server: &base_server_setup @@ -19,7 +18,7 @@ x-server: &base_server_setup DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY?error}, # -- Domain configurations DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-*} - APP_DOMAIN: localhost:800 + APP_DOMAIN: localhost:8000 APP_HTTP_PROTOCOL: http APP_FRONTEND_HOST: localhost:3000 SESSION_COOKIE_DOMAIN: ${SESSION_COOKIE_DOMAIN:-localhost} @@ -41,17 +40,23 @@ x-server: &base_server_setup DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-alert-hub-dev } volumes: - ./:/code + - backend_data:/data/ - ipython_data_local:/root/.ipython/profile_default # persist ipython data, including ipython history depends_on: - db - redis + logging: + driver: "json-file" + options: + max-size: "100m" + max-file: "5" x-worker: &base_worker_setup <<: *base_server_setup environment: <<: *base_server_setup_environment - APP_TYPE: worker + DJANGO_APP_TYPE: worker healthcheck: test: ["CMD-SHELL", "celery -A main inspect ping -d celery@$$HOSTNAME || exit 1"] interval: 30s @@ -118,3 +123,4 @@ volumes: redis-data: ipython_data_local: mailpit-data: + backend_data: diff --git a/main/celery.py b/main/celery.py index 377417a..2f8f20b 100644 --- a/main/celery.py +++ b/main/celery.py @@ -1,12 +1,23 @@ import os from datetime import timedelta -from celery import Celery +import celery +from django.conf import settings from kombu import Queue +from main import sentry + + +class Celery(celery.Celery): + def on_configure(self): # type: ignore[reportIncompatibleVariableOverride] + if settings.SENTRY_ENABLED: + sentry.init_sentry(**settings.SENTRY_CONFIG) + + # TODO: Merge main.settings and main.production os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') + app = Celery('main') app.conf.beat_schedule = { diff --git a/main/sentry.py b/main/sentry.py new file mode 100644 index 0000000..4c1a3c8 --- /dev/null +++ b/main/sentry.py @@ -0,0 +1,36 @@ +import sentry_sdk +from sentry_sdk.integrations.celery import CeleryIntegration +from sentry_sdk.integrations.django import DjangoIntegration +from sentry_sdk.integrations.logging import ignore_logger +from sentry_sdk.integrations.redis import RedisIntegration +from sentry_sdk.integrations.strawberry import StrawberryIntegration +from strawberry.permission import BasePermission + +IGNORED_ERRORS = [ + BasePermission, +] +IGNORED_LOGGERS = [ + "graphql.execution.utils", + "strawberry.http.exceptions.HTTPException", +] + +for _logger in IGNORED_LOGGERS: + ignore_logger(_logger) + + +def init_sentry(app_type, tags={}, **config): + integrations = [ + DjangoIntegration(), + CeleryIntegration(), + RedisIntegration(), + StrawberryIntegration(async_execution=True), + ] + sentry_sdk.init( + **config, + ignore_errors=IGNORED_ERRORS, + integrations=integrations, + ) + with sentry_sdk.configure_scope() as scope: + scope.set_tag("app_type", app_type) + for tag, value in tags.items(): + scope.set_tag(tag, value) diff --git a/main/settings.py b/main/settings.py index 402e89d..7612eb5 100644 --- a/main/settings.py +++ b/main/settings.py @@ -14,6 +14,8 @@ import environ +from main import sentry + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -21,9 +23,10 @@ DJANGO_DEBUG=(bool, False), DJANGO_SECRET_KEY=str, DJANGO_TIME_ZONE=(str, 'UTC'), - DJANGO_APP_TYPE=str, # web/worker TODO: Use this in sentry + DJANGO_APP_TYPE=str, # web/worker DJANGO_APP_ENVIRONMENT=str, # dev/prod # App Domain + APP_RELEASE=(str, 'develop'), APP_DOMAIN=str, # api.example.com APP_HTTP_PROTOCOL=str, # http|https APP_FRONTEND_HOST=str, # http://frontend.example.com @@ -46,6 +49,10 @@ EMAIL_HOST_USER=str, EMAIL_HOST_PASSWORD=str, DEFAULT_FROM_EMAIL=str, + # Sentry + SENTRY_DSN=(str, None), + SENTRY_TRACES_SAMPLE_RATE=(float, 0.2), + SENTRY_PROFILE_SAMPLE_RATE=(float, 0.2), # Misc ) @@ -61,6 +68,7 @@ APP_HTTP_PROTOCOL = env('APP_HTTP_PROTOCOL') APP_DOMAIN = env('APP_DOMAIN') APP_FRONTEND_HOST = env('APP_FRONTEND_HOST') +DJANGO_APP_TYPE = env('DJANGO_APP_TYPE') DJANGO_APP_ENVIRONMENT = env('DJANGO_APP_ENVIRONMENT') @@ -197,6 +205,10 @@ # TODO: Use custom config for static files STATICFILES_DIRS = (str(BASE_DIR.joinpath('static')),) STATIC_URL = 'static/' +STATIC_ROOT = '/data/static' + +MEDIA_URL = 'media/' +MEDIA_ROOT = '/data/media' # Default primary key field type # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field @@ -317,3 +329,25 @@ def log_render_extra_context(record): }, }, } + + +# Sentry Config +SENTRY_DSN = env('SENTRY_DSN') +SENTRY_ENABLED = False + +if SENTRY_DSN: + SENTRY_ENABLED = True + SENTRY_CONFIG = { + 'app_type': DJANGO_APP_TYPE, + 'dsn': SENTRY_DSN, + 'send_default_pii': True, + 'release': env('APP_RELEASE'), + 'environment': DJANGO_APP_ENVIRONMENT, + 'traces_sample_rate': env('SENTRY_TRACES_SAMPLE_RATE'), + 'profiles_sample_rate': env('SENTRY_PROFILE_SAMPLE_RATE'), + 'debug': DEBUG, + 'tags': { + 'site': ','.join(set(ALLOWED_HOSTS)), + }, + } + sentry.init_sentry(**SENTRY_CONFIG) diff --git a/poetry.lock b/poetry.lock index b08c12c..4b3bcac 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "amqp" @@ -612,13 +612,13 @@ test = ["coveralls (>=3.3,<4)", "iso8601 (>=1,<2)", "mock (>=4,<5)", "pytest (>= [[package]] name = "graphene-django" -version = "3.2.0" +version = "3.2.1" description = "Graphene Django integration" optional = false python-versions = "*" files = [ - {file = "graphene-django-3.2.0.tar.gz", hash = "sha256:9aca4a862f12912c2e611624bdbcf6b0f9bc7a41d240110a41bf95575a7bacab"}, - {file = "graphene_django-3.2.0-py2.py3-none-any.whl", hash = "sha256:b553ecdc1cd7fd5b2d71de1a729c03ae117321763a90ed48a7fb4fdbf7f0d43f"}, + {file = "graphene-django-3.2.1.tar.gz", hash = "sha256:52145037872d2575974c4bb2be224756ffeafe5a4e20f9c4367519622965812b"}, + {file = "graphene_django-3.2.1-py2.py3-none-any.whl", hash = "sha256:3fbdd8d4990ecec326c59d68edfcaf9a7bc9c4dbdcbf88b11ac46dfc10240e49"}, ] [package.dependencies] @@ -661,13 +661,13 @@ graphql-core = ">=3.2,<3.3" [[package]] name = "idna" -version = "3.6" +version = "3.7" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" files = [ - {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, - {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, ] [[package]] @@ -683,13 +683,13 @@ files = [ [[package]] name = "ipython" -version = "8.22.2" +version = "8.23.0" description = "IPython: Productive Interactive Computing" optional = false python-versions = ">=3.10" files = [ - {file = "ipython-8.22.2-py3-none-any.whl", hash = "sha256:3c86f284c8f3d8f2b6c662f885c4889a91df7cd52056fd02b7d8d6195d7f56e9"}, - {file = "ipython-8.22.2.tar.gz", hash = "sha256:2dcaad9049f9056f1fef63514f176c7d41f930daa78d05b82a176202818f2c14"}, + {file = "ipython-8.23.0-py3-none-any.whl", hash = "sha256:07232af52a5ba146dc3372c7bf52a0f890a23edf38d77caef8d53f9cdc2584c1"}, + {file = "ipython-8.23.0.tar.gz", hash = "sha256:7468edaf4f6de3e1b912e57f66c241e6fd3c7099f2ec2136e239e142e800274d"}, ] [package.dependencies] @@ -702,12 +702,14 @@ prompt-toolkit = ">=3.0.41,<3.1.0" pygments = ">=2.4.0" stack-data = "*" traitlets = ">=5.13.0" +typing-extensions = {version = "*", markers = "python_version < \"3.12\""} [package.extras] -all = ["ipython[black,doc,kernel,nbconvert,nbformat,notebook,parallel,qtconsole,terminal]", "ipython[test,test-extra]"] +all = ["ipython[black,doc,kernel,matplotlib,nbconvert,nbformat,notebook,parallel,qtconsole]", "ipython[test,test-extra]"] black = ["black"] doc = ["docrepr", "exceptiongroup", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "stack-data", "typing-extensions"] kernel = ["ipykernel"] +matplotlib = ["matplotlib"] nbconvert = ["nbconvert"] nbformat = ["nbformat"] notebook = ["ipywidgets", "notebook"] @@ -748,13 +750,13 @@ testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] [[package]] name = "kombu" -version = "5.3.5" +version = "5.3.7" description = "Messaging library for Python." optional = false python-versions = ">=3.8" files = [ - {file = "kombu-5.3.5-py3-none-any.whl", hash = "sha256:0eac1bbb464afe6fb0924b21bf79460416d25d8abc52546d4f16cad94f789488"}, - {file = "kombu-5.3.5.tar.gz", hash = "sha256:30e470f1a6b49c70dc6f6d13c3e4cc4e178aa6c469ceb6bcd55645385fc84b93"}, + {file = "kombu-5.3.7-py3-none-any.whl", hash = "sha256:5634c511926309c7f9789f1433e9ed402616b56836ef9878f01bd59267b4c7a9"}, + {file = "kombu-5.3.7.tar.gz", hash = "sha256:011c4cd9a355c14a1de8d35d257314a1d2456d52b7140388561acac3cf1a97bf"}, ] [package.dependencies] @@ -771,7 +773,7 @@ mongodb = ["pymongo (>=4.1.1)"] msgpack = ["msgpack"] pyro = ["pyro4"] qpid = ["qpid-python (>=0.26)", "qpid-tools (>=0.26)"] -redis = ["redis (>=4.5.2,!=4.5.5,<6.0.0)"] +redis = ["redis (>=4.5.2,!=4.5.5,!=5.0.2)"] slmq = ["softlayer-messaging (>=1.0.3)"] sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"] sqs = ["boto3 (>=1.26.143)", "pycurl (>=7.43.0.5)", "urllib3 (>=1.26.16)"] @@ -780,13 +782,13 @@ zookeeper = ["kazoo (>=2.8.0)"] [[package]] name = "matplotlib-inline" -version = "0.1.6" +version = "0.1.7" description = "Inline Matplotlib backend for Jupyter" optional = false -python-versions = ">=3.5" +python-versions = ">=3.8" files = [ - {file = "matplotlib-inline-0.1.6.tar.gz", hash = "sha256:f887e5f10ba98e8d2b150ddcf4702c1e5f8b3a20005eb0f74bfdbd360ee6f304"}, - {file = "matplotlib_inline-0.1.6-py3-none-any.whl", hash = "sha256:f1f41aab5328aa5aaea9b16d083b128102f8712542f819fe7e6a420ff581b311"}, + {file = "matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca"}, + {file = "matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90"}, ] [package.dependencies] @@ -850,18 +852,18 @@ files = [ [[package]] name = "parso" -version = "0.8.3" +version = "0.8.4" description = "A Python Parser" optional = false python-versions = ">=3.6" files = [ - {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"}, - {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"}, + {file = "parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18"}, + {file = "parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"}, ] [package.extras] -qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] -testing = ["docopt", "pytest (<6.0.0)"] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["docopt", "pytest"] [[package]] name = "pexpect" @@ -1199,13 +1201,13 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "sentry-sdk" -version = "1.43.0" +version = "1.45.0" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = "*" files = [ - {file = "sentry-sdk-1.43.0.tar.gz", hash = "sha256:41df73af89d22921d8733714fb0fc5586c3461907e06688e6537d01a27e0e0f6"}, - {file = "sentry_sdk-1.43.0-py2.py3-none-any.whl", hash = "sha256:8d768724839ca18d7b4c7463ef7528c40b7aa2bfbf7fe554d5f9a7c044acfd36"}, + {file = "sentry-sdk-1.45.0.tar.gz", hash = "sha256:509aa9678c0512344ca886281766c2e538682f8acfa50fd8d405f8c417ad0625"}, + {file = "sentry_sdk-1.45.0-py2.py3-none-any.whl", hash = "sha256:1ce29e30240cc289a027011103a8c83885b15ef2f316a60bcc7c5300afa144f1"}, ] [package.dependencies] @@ -1314,19 +1316,18 @@ files = [ [[package]] name = "sqlparse" -version = "0.4.4" +version = "0.5.0" description = "A non-validating SQL parser." optional = false -python-versions = ">=3.5" +python-versions = ">=3.8" files = [ - {file = "sqlparse-0.4.4-py3-none-any.whl", hash = "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3"}, - {file = "sqlparse-0.4.4.tar.gz", hash = "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"}, + {file = "sqlparse-0.5.0-py3-none-any.whl", hash = "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663"}, + {file = "sqlparse-0.5.0.tar.gz", hash = "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93"}, ] [package.extras] -dev = ["build", "flake8"] +dev = ["build", "hatch"] doc = ["sphinx"] -test = ["pytest", "pytest-cov"] [[package]] name = "stack-data" @@ -1349,13 +1350,13 @@ tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] [[package]] name = "strawberry-graphql" -version = "0.220.0" +version = "0.225.1" description = "A library for creating GraphQL APIs" optional = false -python-versions = ">=3.8,<4.0" +python-versions = "<4.0,>=3.8" files = [ - {file = "strawberry_graphql-0.220.0-py3-none-any.whl", hash = "sha256:303b698bd3652f852af000085cc2ad7790a9726cdfd7e2f3f5398cbe6d3ef261"}, - {file = "strawberry_graphql-0.220.0.tar.gz", hash = "sha256:916a019e0133c7817aae384161e9ab0e5267013bc2f491b3338b3ae10e8f08bb"}, + {file = "strawberry_graphql-0.225.1-py3-none-any.whl", hash = "sha256:d51f4b04cc2f44453f72791f05b386859922f6ea5244399487b9e716d45c7b6e"}, + {file = "strawberry_graphql-0.225.1.tar.gz", hash = "sha256:b330e7041815db32c6706a30557b936d042472fa0da18c78149aa6ed76ec95de"}, ] [package.dependencies] @@ -1384,16 +1385,17 @@ starlite = ["starlite (>=1.48.0)"] [[package]] name = "strawberry-graphql-django" -version = "0.35.1" +version = "0.37.1" description = "Strawberry GraphQL Django extension" optional = false -python-versions = ">=3.8,<4.0" +python-versions = "<4.0,>=3.8" files = [ - {file = "strawberry_graphql_django-0.35.1-py3-none-any.whl", hash = "sha256:09da5d62c33eacb0e25cd7f5ab179904fd44fb792cb80815a6c99344ffc39bb9"}, - {file = "strawberry_graphql_django-0.35.1.tar.gz", hash = "sha256:f4c28b8a9b89a74b1a78f88898f3140d758ac2b6ef93d7e2e97e5f4480eea7ef"}, + {file = "strawberry_graphql_django-0.37.1-py3-none-any.whl", hash = "sha256:03ff1bf19a9a041ac86cc5985dc72576bede11b893f7cbb2f124bb63014de322"}, + {file = "strawberry_graphql_django-0.37.1.tar.gz", hash = "sha256:cb4ef4ad2bcf26210da73faaa88ca5e51c21e8b3d052a4fa8ed579817d006cbd"}, ] [package.dependencies] +asgiref = ">=3.8" django = ">=3.2" strawberry-graphql = ">=0.212.0" @@ -1451,13 +1453,13 @@ files = [ [[package]] name = "typing-extensions" -version = "4.10.0" +version = "4.11.0" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"}, - {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, + {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, + {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, ] [[package]] @@ -1513,4 +1515,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "9aed97e89110012f2aa36a0e864d72f622fe2ae716f38b997973ca147768ac7f" +content-hash = "1d62a54536ffc98fd1a1c46f2b9f0fc6a8708d11a425d5b5fac40f84373cf7a2" diff --git a/pyproject.toml b/pyproject.toml index 49876df..f42fcec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,8 +23,8 @@ shapely = "^2.0.3" pytz = "*" colorlog = "*" requests = "*" -strawberry-graphql = "^0.220.0" -strawberry-graphql-django = "0.35.1" +strawberry-graphql = "^0.225.0" +strawberry-graphql-django = "0.37.1" sentry-sdk = "*" djangorestframework = "*" diff --git a/schema.graphql b/schema.graphql index 57c8e2a..54499c9 100644 --- a/schema.graphql +++ b/schema.graphql @@ -4,6 +4,7 @@ input Admin1Filter { OR: Admin1Filter NOT: Admin1Filter DISTINCT: Boolean + unknown: Boolean } input Admin1Order { @@ -20,7 +21,10 @@ type Admin1Type { maxLatitude: String minLongitude: String maxLongitude: String + isUnknown: Boolean! country: CountryType! + alertCount: Int! + filteredAlertCount: Int } type Admin1TypeCountList { @@ -32,13 +36,18 @@ type Admin1TypeCountList { input AlertFilter { id: IDBaseFilterLookup - url: StrFilterLookup - sender: StrFilterLookup - admin1s: DjangoModelFilterInput + country: DjangoModelFilterInput + sent: DatetimeDatetimeFilterLookup AND: AlertFilter OR: AlertFilter NOT: AlertFilter DISTINCT: Boolean + region: ID + admin1: ID + urgency: [AlertInfoUrgencyEnum!] + severity: [AlertInfoSeverityEnum!] + certainty: [AlertInfoCertaintyEnum!] + category: [AlertInfoCategoryEnum!] } type AlertInfoAreaCircleType { @@ -222,6 +231,7 @@ type AlertType { country: CountryType! feed: FeedType! admin1s: [Admin1Type!]! + info: AlertInfoType infos: [AlertInfoType!]! } @@ -329,7 +339,9 @@ type CountryType { centroid: String region: RegionType! continent: ContinentType! - admin1s: [Admin1Type!]! + admin1s(alertFilters: AlertFilter = null, includeEmptyFilteredAlertCount: Boolean! = false): [Admin1Type!]! + alertCount: Int! + filteredAlertCount: Int } type CountryTypeCountList { @@ -342,6 +354,52 @@ type CountryTypeCountList { """Date with time (isoformat)""" scalar DateTime +input DatetimeDatetimeFilterLookup { + """Exact match. Filter will be skipped on `null` value""" + exact: DateTime + + """Assignment test. Filter will be skipped on `null` value""" + isNull: Boolean + + """ + Exact match of items in a given list. Filter will be skipped on `null` value + """ + inList: [DateTime!] + + """Greater than. Filter will be skipped on `null` value""" + gt: DateTime + + """Greater than or equal to. Filter will be skipped on `null` value""" + gte: DateTime + + """Less than. Filter will be skipped on `null` value""" + lt: DateTime + + """Less than or equal to. Filter will be skipped on `null` value""" + lte: DateTime + + """Inclusive range test (between)""" + range: DatetimeRangeLookup + year: IntComparisonFilterLookup + month: IntComparisonFilterLookup + day: IntComparisonFilterLookup + weekDay: IntComparisonFilterLookup + isoWeekDay: IntComparisonFilterLookup + week: IntComparisonFilterLookup + isoYear: IntComparisonFilterLookup + quarter: IntComparisonFilterLookup + hour: IntComparisonFilterLookup + minute: IntComparisonFilterLookup + second: IntComparisonFilterLookup + date: IntComparisonFilterLookup + time: IntComparisonFilterLookup +} + +input DatetimeRangeLookup { + start: DateTime = null + end: DateTime = null +} + input DjangoModelFilterInput { pk: ID! } @@ -426,6 +484,39 @@ input IDBaseFilterLookup { inList: [ID!] } +input IntComparisonFilterLookup { + """Exact match. Filter will be skipped on `null` value""" + exact: Int + + """Assignment test. Filter will be skipped on `null` value""" + isNull: Boolean + + """ + Exact match of items in a given list. Filter will be skipped on `null` value + """ + inList: [Int!] + + """Greater than. Filter will be skipped on `null` value""" + gt: Int + + """Greater than or equal to. Filter will be skipped on `null` value""" + gte: Int + + """Less than. Filter will be skipped on `null` value""" + lt: Int + + """Less than or equal to. Filter will be skipped on `null` value""" + lte: Int + + """Inclusive range test (between)""" + range: IntRangeLookup +} + +input IntRangeLookup { + start: Int = null + end: Int = null +} + type LanguageInfoType { id: ID! feedId: ID! @@ -479,6 +570,7 @@ type PublicQuery { alerts(filters: AlertFilter, order: AlertOrder, pagination: OffsetPaginationInput): AlertTypeCountList! alertInfos(filters: AlertInfoFilter, order: AlertInfoOrder, pagination: OffsetPaginationInput): AlertInfoTypeCountList! region(pk: ID!): RegionType + allCountries(alertFilters: AlertFilter = null, includeEmptyFilteredAlertCount: Boolean! = false): [CountryType!]! country(pk: ID!): CountryType admin1(pk: ID!): Admin1Type feed(pk: ID!): FeedType @@ -519,54 +611,6 @@ type RegionTypeCountList { items: [RegionType!]! } -input StrFilterLookup { - """Exact match. Filter will be skipped on `null` value""" - exact: String - - """Assignment test. Filter will be skipped on `null` value""" - isNull: Boolean - - """ - Exact match of items in a given list. Filter will be skipped on `null` value - """ - inList: [String!] - - """Case-insensitive exact match. Filter will be skipped on `null` value""" - iExact: String - - """ - Case-sensitive containment test. Filter will be skipped on `null` value - """ - contains: String - - """ - Case-insensitive containment test. Filter will be skipped on `null` value - """ - iContains: String - - """Case-sensitive starts-with. Filter will be skipped on `null` value""" - startsWith: String - - """Case-insensitive starts-with. Filter will be skipped on `null` value""" - iStartsWith: String - - """Case-sensitive ends-with. Filter will be skipped on `null` value""" - endsWith: String - - """Case-insensitive ends-with. Filter will be skipped on `null` value""" - iEndsWith: String - - """ - Case-sensitive regular expression match. Filter will be skipped on `null` value - """ - regex: String - - """ - Case-insensitive regular expression match. Filter will be skipped on `null` value - """ - iRegex: String -} - type UserMeType { id: ID! firstName: String diff --git a/utils/common.py b/utils/common.py index a05adca..66703a5 100644 --- a/utils/common.py +++ b/utils/common.py @@ -22,9 +22,12 @@ def to_snake_case(name): def get_queryset_for_model( model: typing.Type[models.Model], queryset: models.QuerySet | None = None, + custom_model_method: typing.Callable[..., models.QuerySet] | None = None, ) -> models.QuerySet: if queryset is not None: return copy.deepcopy(queryset) + if custom_model_method: + return custom_model_method() return model.objects.all()