diff --git a/django_announcement/admin/announcement.py b/django_announcement/admin/announcement.py index 300b8f8..a2b7073 100644 --- a/django_announcement/admin/announcement.py +++ b/django_announcement/admin/announcement.py @@ -31,9 +31,9 @@ class AnnouncementAdmin(BaseModelAdmin): ( None, { - "fields": ("title", "content", "category"), + "fields": ("title", "content", "category", "attachment"), "description": _( - "Primary fields related to the announcement, including the title, content, and category." + "Primary fields related to the announcement, including the title, content, category and attachment." ), }, ), diff --git a/django_announcement/api/filters/announcement_filter.py b/django_announcement/api/filters/announcement_filter.py index 9981330..d1338bc 100644 --- a/django_announcement/api/filters/announcement_filter.py +++ b/django_announcement/api/filters/announcement_filter.py @@ -1,8 +1,7 @@ +from django.apps import apps from django_filters import BooleanFilter from django_filters.rest_framework import DateTimeFromToRangeFilter, FilterSet -from django_announcement.models.announcement import Announcement - class AnnouncementFilter(FilterSet): """Filter set for filtering announcements based on various criteria.""" @@ -33,7 +32,7 @@ class AnnouncementFilter(FilterSet): ) class Meta: - model = Announcement + model = None fields = { "audience__id": ["exact"], "category__id": ["exact"], @@ -47,4 +46,7 @@ class Meta: def filter_not_expired(self, queryset, name, value): """Filter announcements that are not expired (i.e., expires_at is None or in the future).""" + from django_announcement.models.announcement import Announcement + + self.Meta.model = Announcement return queryset.active() if value else queryset diff --git a/django_announcement/constants/default_settings.py b/django_announcement/constants/default_settings.py index 232a68f..cc69ef6 100644 --- a/django_announcement/constants/default_settings.py +++ b/django_announcement/constants/default_settings.py @@ -2,6 +2,12 @@ from typing import List, Optional +@dataclass(frozen=True) +class DefaultAttachmentSettings: + validators: Optional[List] = None + upload_path: str = "announcement_attachments/" + + @dataclass(frozen=True) class DefaultCommandSettings: generate_audiences_exclude_apps: List[str] = field(default_factory=lambda: []) diff --git a/django_announcement/constants/types.py b/django_announcement/constants/types.py index d10e8c2..97e1283 100644 --- a/django_announcement/constants/types.py +++ b/django_announcement/constants/types.py @@ -1,4 +1,4 @@ -from typing import Iterable, Union +from typing import Any, Iterable, List, Optional, Type, Union from django_announcement.models.announcement_category import AnnouncementCategory from django_announcement.models.audience import Audience @@ -6,3 +6,7 @@ # Type Alias for Announcement QuerySet Audiences = Union[Audience, int, Iterable[Audience]] Categories = Union[AnnouncementCategory, int, Iterable[AnnouncementCategory]] + +# Type Alias for config class +DefaultPath = Optional[Union[str, List[str]]] +OptionalPaths = Optional[Union[Type[Any], List[Type[Any]]]] diff --git a/django_announcement/models/announcement.py b/django_announcement/models/announcement.py index a750cb5..e7a96a6 100644 --- a/django_announcement/models/announcement.py +++ b/django_announcement/models/announcement.py @@ -16,6 +16,7 @@ from django_announcement.repository.manager.announcement import ( AnnouncementDataAccessLayer, ) +from django_announcement.settings.conf import config class Announcement(TimeStampedModel): @@ -89,7 +90,8 @@ class Announcement(TimeStampedModel): verbose_name=_("Attachment"), help_text=_("An optional file attachment for the announcement (e.g., flyer)."), db_comment="Optional file attachment related to the announcement.", - upload_to="announcement_attachments/", + upload_to=config.attachment_upload_path, + validators=config.attachment_validators or [], blank=True, null=True, ) diff --git a/django_announcement/settings/checks.py b/django_announcement/settings/checks.py index 96f9a5f..969c0e0 100644 --- a/django_announcement/settings/checks.py +++ b/django_announcement/settings/checks.py @@ -6,9 +6,10 @@ from django_announcement.validators.config_validators import ( validate_boolean_setting, validate_list_fields, - validate_optional_class_setting, - validate_optional_classes_setting, + validate_optional_path_setting, + validate_optional_paths_setting, validate_throttle_rate, + validate_upload_path_setting, ) @@ -101,6 +102,11 @@ def check_announcement_settings(app_configs: Any, **kwargs: Any) -> List[Error]: config.api_allow_retrieve, f"{config.prefix}API_ALLOW_RETRIEVE" ) ) + errors.extend( + validate_upload_path_setting( + config.attachment_upload_path, f"{config.prefix}ATTACHMENT_UPLOAD_PATH" + ) + ) errors.extend( validate_list_fields( config.api_ordering_fields, f"{config.prefix}API_ORDERING_FIELDS" @@ -138,37 +144,43 @@ def check_announcement_settings(app_configs: Any, **kwargs: Any) -> List[Error]: ) ) errors.extend( - validate_optional_class_setting( + validate_optional_path_setting( config.get_setting(f"{config.prefix}API_THROTTLE_CLASS", None), f"{config.prefix}API_THROTTLE_CLASS", ) ) errors.extend( - validate_optional_class_setting( + validate_optional_path_setting( config.get_setting(f"{config.prefix}API_PAGINATION_CLASS", None), f"{config.prefix}API_PAGINATION_CLASS", ) ) errors.extend( - validate_optional_classes_setting( + validate_optional_paths_setting( config.get_setting(f"{config.prefix}API_PARSER_CLASSES", []), f"{config.prefix}API_PARSER_CLASSES", ) ) errors.extend( - validate_optional_class_setting( + validate_optional_paths_setting( + config.get_setting(f"{config.prefix}ATTACHMENT_VALIDATORS", []), + f"{config.prefix}ATTACHMENT_VALIDATORS", + ) + ) + errors.extend( + validate_optional_path_setting( config.get_setting(f"{config.prefix}API_FILTERSET_CLASS", None), f"{config.prefix}API_FILTERSET_CLASS", ) ) errors.extend( - validate_optional_class_setting( + validate_optional_path_setting( config.get_setting(f"{config.prefix}API_EXTRA_PERMISSION_CLASS", None), f"{config.prefix}API_EXTRA_PERMISSION_CLASS", ) ) errors.extend( - validate_optional_class_setting( + validate_optional_path_setting( config.get_setting(f"{config.prefix}ADMIN_SITE_CLASS", None), f"{config.prefix}ADMIN_SITE_CLASS", ) diff --git a/django_announcement/settings/conf.py b/django_announcement/settings/conf.py index d548a60..66677db 100644 --- a/django_announcement/settings/conf.py +++ b/django_announcement/settings/conf.py @@ -1,4 +1,4 @@ -from typing import Any, List, Optional, Type, Union +from typing import Any, List from django.conf import settings from django.utils.module_loading import import_string @@ -6,11 +6,13 @@ from django_announcement.constants.default_settings import ( DefaultAdminSettings, DefaultAPISettings, + DefaultAttachmentSettings, DefaultCommandSettings, DefaultPaginationAndFilteringSettings, DefaultSerializerSettings, DefaultThrottleSettings, ) +from django_announcement.constants.types import DefaultPath, OptionalPaths # pylint: disable=too-many-instance-attributes @@ -56,6 +58,7 @@ class AnnouncementConfig: ) default_throttle_settings: DefaultThrottleSettings = DefaultThrottleSettings() default_command_settings: DefaultCommandSettings = DefaultCommandSettings() + default_attachment_settings: DefaultAttachmentSettings = DefaultAttachmentSettings() def __init__(self) -> None: """Initialize the AnnouncementConfig, loading values from Django @@ -114,6 +117,14 @@ def __init__(self) -> None: f"{self.prefix}GENERATE_AUDIENCES_EXCLUDE_MODELS", self.default_command_settings.generate_audiences_exclude_models, ) + self.attachment_upload_path: str = self.get_setting( + f"{self.prefix}ATTACHMENT_UPLOAD_PATH", + self.default_attachment_settings.upload_path, + ) + self.attachment_validators: OptionalPaths = self.get_optional_paths( + f"{self.prefix}ATTACHMENT_VALIDATORS", + self.default_attachment_settings.validators, + ) self.authenticated_user_throttle_rate: str = self.get_setting( f"{self.prefix}AUTHENTICATED_USER_THROTTLE_RATE", self.default_throttle_settings.authenticated_user_throttle_rate, @@ -122,25 +133,23 @@ def __init__(self) -> None: f"{self.prefix}STAFF_USER_THROTTLE_RATE", self.default_throttle_settings.staff_user_throttle_rate, ) - self.api_throttle_class: Optional[Type[Any]] = self.get_optional_classes( + self.api_throttle_class: OptionalPaths = self.get_optional_paths( f"{self.prefix}API_THROTTLE_CLASS", self.default_throttle_settings.throttle_class, ) - self.api_pagination_class: Optional[Type[Any]] = self.get_optional_classes( + self.api_pagination_class: OptionalPaths = self.get_optional_paths( f"{self.prefix}API_PAGINATION_CLASS", self.default_pagination_and_filter_settings.pagination_class, ) - self.api_extra_permission_class: Optional[Type[Any]] = ( - self.get_optional_classes( - f"{self.prefix}API_EXTRA_PERMISSION_CLASS", - self.default_api_settings.extra_permission_class, - ) + self.api_extra_permission_class: OptionalPaths = self.get_optional_paths( + f"{self.prefix}API_EXTRA_PERMISSION_CLASS", + self.default_api_settings.extra_permission_class, ) - self.api_parser_classes: Optional[List[Type[Any]]] = self.get_optional_classes( + self.api_parser_classes: OptionalPaths = self.get_optional_paths( f"{self.prefix}API_PARSER_CLASSES", self.default_api_settings.parser_classes, ) - self.api_filterset_class: Optional[Type[Any]] = self.get_optional_classes( + self.api_filterset_class: OptionalPaths = self.get_optional_paths( f"{self.prefix}API_FILTERSET_CLASS", self.default_pagination_and_filter_settings.filterset_class, ) @@ -152,7 +161,7 @@ def __init__(self) -> None: f"{self.prefix}API_SEARCH_FIELDS", self.default_pagination_and_filter_settings.search_fields, ) - self.admin_site_class: Optional[Type[Any]] = self.get_optional_classes( + self.admin_site_class: OptionalPaths = self.get_optional_paths( f"{self.prefix}ADMIN_SITE_CLASS", self.default_admin_settings.admin_site_class, ) @@ -170,39 +179,33 @@ def get_setting(self, setting_name: str, default_value: Any) -> Any: """ return getattr(settings, setting_name, default_value) - def get_optional_classes( + def get_optional_paths( self, setting_name: str, - default_path: Optional[Union[str, List[str]]], - ) -> Optional[Union[Type[Any], List[Type[Any]]]]: - """Dynamically load a class based on a setting, or return None if the - setting is None or invalid. + default_path: DefaultPath, + ) -> OptionalPaths: + """Dynamically load a method or class path on a setting, or return None + if the setting is None or invalid. Args: - setting_name (str): The name of the setting for the class path. - default_path (Optional[Union[str, List[str]]): The default import path for the class. + setting_name (str): The name of the setting for the method or class path. + default_path (Optional[Union[str, List[str]]): The default import path for the method or class. Returns: - Optional[Union[Type[Any], List[Type[Any]]]]: The imported class or None + Optional[Union[Type[Any], List[Type[Any]]]]: The imported method or class or None if import fails or the path is invalid. """ - class_path: Optional[Union[str, List[str]]] = self.get_setting( - setting_name, default_path - ) + _path: DefaultPath = self.get_setting(setting_name, default_path) - if class_path and isinstance(class_path, str): + if _path and isinstance(_path, str): try: - return import_string(class_path) + return import_string(_path) except ImportError: return None - elif class_path and isinstance(class_path, list): + elif _path and isinstance(_path, list): try: - return [ - import_string(cls_path) - for cls_path in class_path - if isinstance(cls_path, str) - ] + return [import_string(path) for path in _path if isinstance(path, str)] except ImportError: return [] diff --git a/django_announcement/tests/admin/test_announcement.py b/django_announcement/tests/admin/test_announcement.py index ebb6219..4e1fdfb 100644 --- a/django_announcement/tests/admin/test_announcement.py +++ b/django_announcement/tests/admin/test_announcement.py @@ -141,9 +141,9 @@ def test_fieldsets(self, announcement_admin: AnnouncementAdmin) -> None: ( None, { - "fields": ("title", "content", "category"), + "fields": ("title", "content", "category", "attachment"), "description": "Primary fields related to the announcement," - " including the title, content, and category.", + " including the title, content, category and attachment.", }, ), ( diff --git a/django_announcement/tests/settings/test_checks.py b/django_announcement/tests/settings/test_checks.py index 96fa249..e225ded 100644 --- a/django_announcement/tests/settings/test_checks.py +++ b/django_announcement/tests/settings/test_checks.py @@ -39,6 +39,8 @@ def test_valid_settings(self, mock_config: MagicMock) -> None: mock_config.exclude_serializer_empty_fields = True mock_config.api_allow_list = True mock_config.api_allow_retrieve = False + mock_config.attachment_upload_path = "test_path/" + mock_config.attachment_validators = [] mock_config.api_ordering_fields = ["created_at"] mock_config.api_search_fields = ["id"] mock_config.staff_user_throttle_rate = "10/minute" @@ -81,6 +83,8 @@ def test_invalid_boolean_settings(self, mock_config: MagicMock) -> None: mock_config.authenticated_user_throttle_rate = "5/minute" mock_config.api_allow_list = "not_boolean" mock_config.api_allow_retrieve = "not_boolean" + mock_config.attachment_upload_path = "test_path/" + mock_config.attachment_validators = [] mock_config.generate_audiences_exclude_apps = [] mock_config.generate_audiences_exclude_models = [] mock_config.get_setting.side_effect = lambda name, default: None @@ -160,6 +164,8 @@ def test_invalid_list_settings(self, mock_config: MagicMock) -> None: mock_config.api_allow_list = True mock_config.api_allow_retrieve = False mock_config.api_ordering_fields = [] + mock_config.attachment_upload_path = "test_path/" + mock_config.attachment_validators = [] mock_config.staff_user_throttle_rate = "10/minute" mock_config.authenticated_user_throttle_rate = "5/minute" mock_config.generate_audiences_exclude_apps = None @@ -213,6 +219,8 @@ def test_invalid_throttle_rate(self, mock_config: MagicMock) -> None: mock_config.exclude_serializer_empty_fields = True mock_config.api_allow_list = True mock_config.api_allow_retrieve = False + mock_config.attachment_upload_path = "test_path/" + mock_config.attachment_validators = [] mock_config.api_ordering_fields = ["created_at"] mock_config.api_search_fields = ["id"] mock_config.staff_user_throttle_rate = "invalid_rate" @@ -229,19 +237,19 @@ def test_invalid_throttle_rate(self, mock_config: MagicMock) -> None: assert errors[1].id == "django_announcement.E007" @patch("django_announcement.settings.checks.config") - def test_invalid_class_import(self, mock_config: MagicMock) -> None: + def test_invalid_path_import(self, mock_config: MagicMock) -> None: """ - Test that invalid class import settings return errors. + Test that invalid path import settings return errors. Args: ---- - mock_config (MagicMock): Mocked configuration object with invalid class paths. + mock_config (MagicMock): Mocked configuration object with invalid paths. Asserts: ------- - Seven errors are returned for invalid class imports. + Seven errors are returned for invalid path imports. """ - # Mock the config values with invalid class paths + # Mock the config values with invalid paths mock_config.admin_has_add_permission = False mock_config.admin_has_change_permission = False mock_config.admin_has_delete_permission = False @@ -259,35 +267,45 @@ def test_invalid_class_import(self, mock_config: MagicMock) -> None: mock_config.authenticated_user_throttle_rate = "5/minute" mock_config.generate_audiences_exclude_apps = [] mock_config.generate_audiences_exclude_models = [] + mock_config.attachment_upload_path = [] # invalid,should be str mock_config.get_setting.side_effect = ( lambda name, default: "invalid.path.ClassName" ) errors = check_announcement_settings(None) - # Expect 6 errors for invalid class imports - assert len(errors) == 6 + # Expect 8 errors for invalid path imports + assert len(errors) == 8 + assert ( - errors[0].id - == f"django_announcement.E010_{mock_config.prefix}API_THROTTLE_CLASS" + errors[0].id + == f"django_announcement.E014_{mock_config.prefix}ATTACHMENT_UPLOAD_PATH" ) assert ( errors[1].id - == f"django_announcement.E010_{mock_config.prefix}API_PAGINATION_CLASS" + == f"django_announcement.E010_{mock_config.prefix}API_THROTTLE_CLASS" ) assert ( errors[2].id - == f"django_announcement.E011_{mock_config.prefix}API_PARSER_CLASSES" + == f"django_announcement.E010_{mock_config.prefix}API_PAGINATION_CLASS" ) assert ( errors[3].id + == f"django_announcement.E011_{mock_config.prefix}API_PARSER_CLASSES" + ) + assert ( + errors[4].id + == f"django_announcement.E011_{mock_config.prefix}ATTACHMENT_VALIDATORS" + ) + assert ( + errors[5].id == f"django_announcement.E010_{mock_config.prefix}API_FILTERSET_CLASS" ) assert ( - errors[4].id + errors[6].id == f"django_announcement.E010_{mock_config.prefix}API_EXTRA_PERMISSION_CLASS" ) assert ( - errors[5].id + errors[7].id == f"django_announcement.E010_{mock_config.prefix}ADMIN_SITE_CLASS" ) diff --git a/django_announcement/tests/settings/test_conf.py b/django_announcement/tests/settings/test_conf.py index 30cf4cd..659fef2 100644 --- a/django_announcement/tests/settings/test_conf.py +++ b/django_announcement/tests/settings/test_conf.py @@ -21,7 +21,7 @@ class TestNotificationConfig: def test_import_error_handling(self) -> None: """ - Test that `get_optional_classes` handles ImportError and returns None when an invalid class path is provided. + Test that `get_optional_paths` handles ImportError and returns None when an invalid path is provided. Args: ---- @@ -29,21 +29,21 @@ def test_import_error_handling(self) -> None: Asserts: ------- - The result of `get_optional_classes` should be None when ImportError is raised. + The result of `get_optional_paths` should be None when ImportError is raised. """ config = AnnouncementConfig() with patch( "django.utils.module_loading.import_string", side_effect=ImportError ): - result = config.get_optional_classes( + result = config.get_optional_paths( "INVALID_SETTING", "invalid.path.ClassName" ) assert result is None def test_invalid_class_path_handling(self) -> None: """ - Test that `get_optional_classes` returns None when an invalid class path is given (non-string). + Test that `get_optional_classes` returns None when an invalid path is given (non-string). Args: ---- @@ -51,11 +51,11 @@ def test_invalid_class_path_handling(self) -> None: Asserts: ------- - The result of `get_optional_classes` should be None when a non-string path is provided. + The result of `get_optional_paths` should be None when a non-string path is provided. """ config = AnnouncementConfig() - result = config.get_optional_classes("INVALID_SETTING", None) + result = config.get_optional_paths("INVALID_SETTING", None) assert result is None - result = config.get_optional_classes("INVALID_SETTING", ["INVALID_PATH"]) + result = config.get_optional_paths("INVALID_SETTING", ["INVALID_PATH"]) assert not result diff --git a/django_announcement/tests/validators/test_config_validators.py b/django_announcement/tests/validators/test_config_validators.py index 20748f9..b2e2775 100644 --- a/django_announcement/tests/validators/test_config_validators.py +++ b/django_announcement/tests/validators/test_config_validators.py @@ -7,8 +7,8 @@ from django_announcement.validators.config_validators import ( validate_boolean_setting, validate_list_fields, - validate_optional_class_setting, - validate_optional_classes_setting, + validate_optional_path_setting, + validate_optional_paths_setting, validate_throttle_rate, ) @@ -200,10 +200,9 @@ def test_valid_class_import(self) -> None: The result should have no errors. """ with patch("django.utils.module_loading.import_string"): - errors = validate_optional_class_setting( + errors = validate_optional_path_setting( "django_announcement.api.throttlings.role_base_throttle.RoleBasedUserRateThrottle", - "SOME_CLASS_SETTING", - ) + "SOME_CLASS_SETTING") assert not errors def test_invalid_class_import(self) -> None: @@ -221,9 +220,7 @@ def test_invalid_class_import(self) -> None: with patch( "django.utils.module_loading.import_string", side_effect=ImportError ): - errors = validate_optional_class_setting( - "invalid.path.ClassName", "SOME_CLASS_SETTING" - ) + errors = validate_optional_path_setting("invalid.path.ClassName", "SOME_CLASS_SETTING") assert len(errors) == 1 assert errors[0].id == "django_announcement.E010_SOME_CLASS_SETTING" @@ -239,7 +236,7 @@ def test_invalid_class_path_type(self) -> None: ------- The result should contain one error with the expected error ID for non-string class paths. """ - errors = validate_optional_class_setting(12345, "SOME_CLASS_SETTING") # type: ignore + errors = validate_optional_path_setting(12345, "SOME_CLASS_SETTING") # type: ignore assert len(errors) == 1 assert errors[0].id == "django_announcement.E009_SOME_CLASS_SETTING" @@ -255,7 +252,7 @@ def test_none_class_path(self) -> None: ------- The result should have no errors. """ - errors = validate_optional_class_setting(None, "SOME_CLASS_SETTING") # type: ignore + errors = validate_optional_path_setting(None, "SOME_CLASS_SETTING") # type: ignore assert not errors def test_invalid_list_args_classes_import(self) -> None: @@ -270,9 +267,7 @@ def test_invalid_list_args_classes_import(self) -> None: ------- The result should contain errors for each invalid class path with the expected error ID. """ - errors = validate_optional_classes_setting( - [1, 5], "SOME_CLASS_SETTING" # type: ignore - ) + errors = validate_optional_paths_setting([1, 5], "SOME_CLASS_SETTING") assert len(errors) == 2 assert errors[0].id == "django_announcement.E012_SOME_CLASS_SETTING" @@ -291,8 +286,6 @@ def test_invalid_path_classes_import(self) -> None: with patch( "django.utils.module_loading.import_string", side_effect=ImportError ): - errors = validate_optional_classes_setting( - ["INVALID_PATH"], "SOME_CLASS_SETTING" - ) + errors = validate_optional_paths_setting(["INVALID_PATH"], "SOME_CLASS_SETTING") assert len(errors) == 1 assert errors[0].id == "django_announcement.E013_SOME_CLASS_SETTING" diff --git a/django_announcement/validators/config_validators.py b/django_announcement/validators/config_validators.py index 82701a3..bc5638b 100644 --- a/django_announcement/validators/config_validators.py +++ b/django_announcement/validators/config_validators.py @@ -1,3 +1,4 @@ +from re import match from typing import List from django.core.checks import Error @@ -109,13 +110,14 @@ def validate_throttle_rate(rate: str, setting_name: str) -> List[Error]: return errors -def validate_optional_class_setting( +def validate_optional_path_setting( setting_value: str, setting_name: str ) -> List[Error]: - """Validate that the setting is a valid class path and can be imported. + """Validate that the setting is a valid method or class path and can be + imported. Args: - setting_value (str): The value of the setting to validate, typically a class path. + setting_value (str): The value of the setting to validate, typically a method or class path. setting_name (str): The name of the setting being validated (for error reporting). Returns: @@ -131,8 +133,9 @@ def validate_optional_class_setting( if not isinstance(setting_value, str): errors.append( Error( - f"The setting '{setting_name}' must be a valid string representing a class path.", - hint=f"Ensure '{setting_name}' is set to a string (e.g., 'myapp.module.MyClass').", + f"The setting '{setting_name}' must be a valid string representing a method or class path.", + hint=f"Ensure '{setting_name}' is set to a string (e.g., 'myapp.module.MyClass'," + " myapp.module.my_method).", id=f"django_announcement.E009_{setting_name}", ) ) @@ -144,8 +147,8 @@ def validate_optional_class_setting( except ImportError: errors.append( Error( - f"Cannot import the class from the setting '{setting_name}'.", - hint=f"Ensure the class path '{setting_value}' is valid and importable.", + f"Cannot import any method or class from the setting '{setting_name}'.", + hint=f"Ensure the path '{setting_value}' is valid and importable.", id=f"django_announcement.E010_{setting_name}", ) ) @@ -153,14 +156,14 @@ def validate_optional_class_setting( return errors -def validate_optional_classes_setting( +def validate_optional_paths_setting( setting_value: List[str], setting_name: str ) -> List[Error]: - """Validate that the setting value is a list of class paths and ensure that - they can be imported. + """Validate that the setting value is a list of paths and ensure that they + can be imported. Args: - setting_value (List[str]): The setting value to validate, typically a list of class paths. + setting_value (List[str]): The setting value to validate, typically a list of method or class paths. setting_name (str): The name of the setting being validated (for error reporting). Returns: @@ -177,7 +180,7 @@ def validate_optional_classes_setting( errors.append( Error( f"Invalid type for setting '{setting_name}'.", - hint="The setting must be either a list of strings. (e.g., ['myapp.module.MyClass'])", + hint="The setting must be either a list of strings.", id=f"django_announcement.E011_{setting_name}", ) ) @@ -188,22 +191,48 @@ def validate_optional_classes_setting( if not isinstance(path, str): errors.append( Error( - f"Invalid type for class path in '{setting_name}'.", - hint="Each item in the list must be a valid string representing a class path.", + f"Invalid type for path in '{setting_name}'.", + hint="Each item in the list must be a valid string representing a path.", id=f"django_announcement.E012_{setting_name}", ) ) else: - # Attempt to import the class from the given path + # Attempt to import the method or path from the given path try: import_string(path) except ImportError: errors.append( Error( - f"Cannot import the class from '{path}' in setting '{setting_name}'.", - hint=f"Ensure that '{path}' is a valid importable class path.", + f"Cannot import the path from '{path}' in setting '{setting_name}'.", + hint=f"Ensure that '{path}' is a valid importable path.", id=f"django_announcement.E013_{setting_name}", ) ) return errors + + +def validate_upload_path_setting(path: str, setting_name: str) -> List[Error]: + """Validate that the upload path is a proper string and follows a basic + path structure. + + Args: + path (str): The upload path to validate. + setting_name (str): The name of the setting being validated. + + Returns: + List[Error]: A list of validation errors if invalid, or an empty list if valid. + + """ + errors = [] + + if not isinstance(path, str) or not path: + errors.append( + Error( + f"{setting_name} must be a non-empty string.", + hint="Ensure the upload path is provided as a non-empty string (e.g., 'uploads/announcements/').", + id=f"django_announcement.E014_{setting_name}", + ) + ) + + return errors