diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 953124f45..7d7624f60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: matrix: os: [ubuntu-latest] # Add macos-latest later? python-version: ['3.8'] - toxenv: ["py38-django32", "py38-django42", "package"] + toxenv: ["py38-django32", "py38-django42", "package", "quality"] # We're only testing against MySQL 8 right now because 5.7 is # incompatible with Djagno 4.2. We'd have to make the tox.ini file more # complicated than it's worth given the short expected shelf-life of diff --git a/.gitignore b/.gitignore index 0fc03c7df..6b5c4fed5 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ pip-log.txt # Unit test / coverage reports .cache/ .pytest_cache/ +.mypy_cache/ .coverage .coverage.* .tox diff --git a/docs/conf.py b/docs/conf.py index 763a5ffb6..6308e2c23 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -524,6 +524,8 @@ def on_init(app): # pylint: disable=unused-argument def setup(app): - """Sphinx extension: run sphinx-apidoc.""" + """ + Sphinx extension: run sphinx-apidoc. + """ event = 'builder-inited' app.connect(event, on_init) diff --git a/manage.py b/manage.py index 0bf631df6..640c5043f 100644 --- a/manage.py +++ b/manage.py @@ -18,7 +18,7 @@ # issue is really that Django is missing to avoid masking other # exceptions on Python 2. try: - import django # pylint: disable=unused-import, wrong-import-position + import django # pylint: disable=unused-import except ImportError as import_error: raise ImportError( "Couldn't import Django. Are you sure it's installed and " diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 000000000..d3bb5b9f8 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,15 @@ +[mypy] +follow_imports = normal +ignore_missing_imports = False +allow_untyped_globals = False +plugins = + mypy_django_plugin.main, + mypy_drf_plugin.main +files = + openedx_learning, + openedx_tagging, + tests + +[mypy.plugins.django-stubs] +# content_staging only works with CMS; others work with either, so we run mypy with CMS settings. +django_settings_module = "projects.dev" diff --git a/olx_importer/management/commands/load_components.py b/olx_importer/management/commands/load_components.py index d987751b8..666eeeedc 100644 --- a/olx_importer/management/commands/load_components.py +++ b/olx_importer/management/commands/load_components.py @@ -46,7 +46,9 @@ def __init__(self, *args, **kwargs): self.init_known_types() def init_known_types(self): - """Intialize mimetypes with some custom mappings we want to use.""" + """ + Intialize mimetypes with some custom mappings we want to use. + """ # This is our own hacky video transcripts related format. mimetypes.add_type("application/vnd.openedx.srt+json", ".sjson") diff --git a/openedx_learning/__init__.py b/openedx_learning/__init__.py index 1276d0254..5f1cea0b2 100644 --- a/openedx_learning/__init__.py +++ b/openedx_learning/__init__.py @@ -1 +1,4 @@ +""" +Open edX Learning ("Learning Core"). +""" __version__ = "0.1.5" diff --git a/openedx_learning/contrib/media_server/__init__.py b/openedx_learning/contrib/media_server/__init__.py index e69de29bb..623258f2e 100644 --- a/openedx_learning/contrib/media_server/__init__.py +++ b/openedx_learning/contrib/media_server/__init__.py @@ -0,0 +1,3 @@ +""" +Media server (for dev or low-traffic instances) +""" diff --git a/openedx_learning/contrib/media_server/apps.py b/openedx_learning/contrib/media_server/apps.py index 2612be027..77ac37b34 100644 --- a/openedx_learning/contrib/media_server/apps.py +++ b/openedx_learning/contrib/media_server/apps.py @@ -1,6 +1,9 @@ +""" +Django app metadata for the Media Server application. +""" from django.apps import AppConfig -from django.core.exceptions import ImproperlyConfigured from django.conf import settings +from django.core.exceptions import ImproperlyConfigured class MediaServerConfig(AppConfig): diff --git a/openedx_learning/contrib/media_server/urls.py b/openedx_learning/contrib/media_server/urls.py index a76155dec..8e7604fdd 100644 --- a/openedx_learning/contrib/media_server/urls.py +++ b/openedx_learning/contrib/media_server/urls.py @@ -1,3 +1,6 @@ +""" +URLs for the media server application +""" from django.urls import path from .views import component_asset diff --git a/openedx_learning/contrib/media_server/views.py b/openedx_learning/contrib/media_server/views.py index d376283eb..752c915cc 100644 --- a/openedx_learning/contrib/media_server/views.py +++ b/openedx_learning/contrib/media_server/views.py @@ -1,8 +1,12 @@ +""" +Views for the media server application + +(serves media files in dev or low-traffic instances). +""" from pathlib import Path -from django.http import FileResponse -from django.http import Http404 from django.core.exceptions import ObjectDoesNotExist, PermissionDenied +from django.http import FileResponse, Http404 from openedx_learning.core.components.api import get_component_version_content @@ -28,7 +32,7 @@ def component_asset( learning_package_key, component_key, version_num, asset_path ) except ObjectDoesNotExist: - raise Http404("File not found") + raise Http404("File not found") # pylint: disable=raise-missing-from if not cvc.learner_downloadable and not ( request.user and request.user.is_superuser diff --git a/openedx_learning/core/components/admin.py b/openedx_learning/core/components/admin.py index 34f091d92..5710303ac 100644 --- a/openedx_learning/core/components/admin.py +++ b/openedx_learning/core/components/admin.py @@ -1,22 +1,27 @@ +""" +Django admin for components models +""" from django.contrib import admin from django.template.defaultfilters import filesizeformat from django.urls import reverse from django.utils.html import format_html +from django.utils.safestring import SafeText -from .models import ( - Component, - ComponentVersion, - ComponentVersionRawContent, -) from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin +from .models import Component, ComponentVersion, ComponentVersionRawContent + class ComponentVersionInline(admin.TabularInline): + """ + Inline admin view of ComponentVersion from the Component Admin + """ model = ComponentVersion fields = ["version_num", "created", "title", "format_uuid"] readonly_fields = ["version_num", "created", "title", "uuid", "format_uuid"] extra = 0 + @admin.display(description="UUID") def format_uuid(self, cv_obj): return format_html( '{}', @@ -24,11 +29,12 @@ def format_uuid(self, cv_obj): cv_obj.uuid, ) - format_uuid.short_description = "UUID" - @admin.register(Component) class ComponentAdmin(ReadOnlyModelAdmin): + """ + Django admin configuration for Component + """ list_display = ("key", "uuid", "namespace", "type", "created") readonly_fields = [ "learning_package", @@ -44,6 +50,9 @@ class ComponentAdmin(ReadOnlyModelAdmin): class RawContentInline(admin.TabularInline): + """ + Django admin configuration for RawContent + """ model = ComponentVersion.raw_contents.through def get_queryset(self, request): @@ -75,11 +84,11 @@ def get_queryset(self, request): def rendered_data(self, cvc_obj): return content_preview(cvc_obj) + @admin.display(description="Size") def format_size(self, cvc_obj): return filesizeformat(cvc_obj.raw_content.size) - format_size.short_description = "Size" - + @admin.display(description="Key") def format_key(self, cvc_obj): return format_html( '{}', @@ -88,11 +97,12 @@ def format_key(self, cvc_obj): cvc_obj.key, ) - format_key.short_description = "Key" - @admin.register(ComponentVersion) class ComponentVersionAdmin(ReadOnlyModelAdmin): + """ + Django admin configuration for ComponentVersion + """ readonly_fields = [ "component", "uuid", @@ -120,29 +130,10 @@ def get_queryset(self, request): ) -def is_displayable_text(mime_type): - # Our usual text files, including things like text/markdown, text/html - media_type, media_subtype = mime_type.split("/") - - if media_type == "text": - return True - - # Our OLX goes here, but so do some other things like - if media_subtype.endswith("+xml"): - return True - - # Other application/* types that we know we can display. - if media_subtype in ["json", "x-subrip"]: - return True - - # Other formats that are really specific types of JSON - if media_subtype.endswith("+json"): - return True - - return False - - -def link_for_cvc(cvc_obj: ComponentVersionRawContent): +def link_for_cvc(cvc_obj: ComponentVersionRawContent) -> str: + """ + Get the download URL for the given ComponentVersionRawContent instance + """ return "/media_server/component_asset/{}/{}/{}/{}".format( cvc_obj.raw_content.learning_package.key, cvc_obj.component_version.component.key, @@ -151,14 +142,20 @@ def link_for_cvc(cvc_obj: ComponentVersionRawContent): ) -def format_text_for_admin_display(text): +def format_text_for_admin_display(text: str) -> SafeText: + """ + Get the HTML to display the given plain text (preserving formatting) + """ return format_html( '
\n{}\n',
text,
)
-def content_preview(cvc_obj: ComponentVersionRawContent):
+def content_preview(cvc_obj: ComponentVersionRawContent) -> SafeText:
+ """
+ Get the HTML to display a preview of the given ComponentVersionRawContent
+ """
raw_content_obj = cvc_obj.raw_content
if raw_content_obj.mime_type.startswith("image/"):
diff --git a/openedx_learning/core/components/api.py b/openedx_learning/core/components/api.py
index 1a3972dec..61e7e574f 100644
--- a/openedx_learning/core/components/api.py
+++ b/openedx_learning/core/components/api.py
@@ -10,21 +10,29 @@
Please look at the models.py file for more information about the kinds of data
are stored in this app.
"""
+from __future__ import annotations
+
+from datetime import datetime
from pathlib import Path
from django.db.models import Q
from django.db.transaction import atomic
-from ..publishing.api import (
- create_publishable_entity,
- create_publishable_entity_version,
-)
-from .models import ComponentVersionRawContent, Component, ComponentVersion
+from ..publishing.api import create_publishable_entity, create_publishable_entity_version
+from .models import Component, ComponentVersion, ComponentVersionRawContent
def create_component(
- learning_package_id, namespace, type, local_key, created, created_by
+ learning_package_id: int,
+ namespace: str,
+ type: str, # pylint: disable=redefined-builtin
+ local_key: str,
+ created: datetime,
+ created_by: int | None,
):
+ """
+ Create a new Component (an entity like a Problem or Video)
+ """
key = f"{namespace}:{type}@{local_key}"
with atomic():
publishable_entity = create_publishable_entity(
@@ -40,7 +48,16 @@ def create_component(
return component
-def create_component_version(component_pk, version_num, title, created, created_by):
+def create_component_version(
+ component_pk: int,
+ version_num: int,
+ title: str,
+ created: datetime,
+ created_by: int | None,
+) -> ComponentVersion:
+ """
+ Create a new ComponentVersion
+ """
with atomic():
publishable_entity_version = create_publishable_entity_version(
entity_id=component_pk,
@@ -57,8 +74,17 @@ def create_component_version(component_pk, version_num, title, created, created_
def create_component_and_version(
- learning_package_id, namespace, type, local_key, title, created, created_by
+ learning_package_id: int,
+ namespace: str,
+ type: str, # pylint: disable=redefined-builtin
+ local_key: str,
+ title: str,
+ created: datetime,
+ created_by: int | None,
):
+ """
+ Create a Component and associated ComponentVersion atomically
+ """
with atomic():
component = create_component(
learning_package_id, namespace, type, local_key, created, created_by
@@ -73,7 +99,7 @@ def create_component_and_version(
return (component, component_version)
-def get_component_by_pk(component_pk):
+def get_component_by_pk(component_pk: int) -> Component:
return Component.objects.get(pk=component_pk)
@@ -103,8 +129,14 @@ def get_component_version_content(
def add_content_to_component_version(
- component_version, raw_content_id, key, learner_downloadable=False
-):
+ component_version: ComponentVersion,
+ raw_content_id: int,
+ key: str,
+ learner_downloadable=False,
+) -> ComponentVersionRawContent:
+ """
+ Add a RawContent to the given ComponentVersion
+ """
cvrc, _created = ComponentVersionRawContent.objects.get_or_create(
component_version=component_version,
raw_content_id=raw_content_id,
diff --git a/openedx_learning/core/components/apps.py b/openedx_learning/core/components/apps.py
index 9b25954ac..60ff42ad4 100644
--- a/openedx_learning/core/components/apps.py
+++ b/openedx_learning/core/components/apps.py
@@ -1,3 +1,6 @@
+"""
+Django metadata for the Components Django application.
+"""
from django.apps import AppConfig
@@ -15,7 +18,7 @@ def ready(self):
"""
Register Component and ComponentVersion.
"""
- from ..publishing.api import register_content_models
- from .models import Component, ComponentVersion
+ from ..publishing.api import register_content_models # pylint: disable=import-outside-toplevel
+ from .models import Component, ComponentVersion # pylint: disable=import-outside-toplevel
register_content_models(Component, ComponentVersion)
diff --git a/openedx_learning/core/components/migrations/0001_initial.py b/openedx_learning/core/components/migrations/0001_initial.py
index 1d3707476..0ca82b95c 100644
--- a/openedx_learning/core/components/migrations/0001_initial.py
+++ b/openedx_learning/core/components/migrations/0001_initial.py
@@ -1,9 +1,11 @@
# Generated by Django 3.2.19 on 2023-06-15 14:43
-from django.db import migrations, models
+import uuid
+
import django.db.models.deletion
+from django.db import migrations, models
+
import openedx_learning.lib.fields
-import uuid
class Migration(migrations.Migration):
diff --git a/openedx_learning/core/components/models.py b/openedx_learning/core/components/models.py
index 2d2f21391..35910ba21 100644
--- a/openedx_learning/core/components/models.py
+++ b/openedx_learning/core/components/models.py
@@ -16,22 +16,18 @@
by convention, but it's possible we might want to have special identifiers
later.
"""
+from __future__ import annotations
+
from django.db import models
-from openedx_learning.lib.fields import (
- case_sensitive_char_field,
- immutable_uuid_field,
- key_field,
-)
-from ..publishing.models import LearningPackage
-from ..publishing.model_mixins import (
- PublishableEntityMixin,
- PublishableEntityVersionMixin,
-)
+from openedx_learning.lib.fields import case_sensitive_char_field, immutable_uuid_field, key_field
+
from ..contents.models import RawContent
+from ..publishing.model_mixins import PublishableEntityMixin, PublishableEntityVersionMixin
+from ..publishing.models import LearningPackage
-class Component(PublishableEntityMixin):
+class Component(PublishableEntityMixin): # type: ignore[django-manager-missing]
"""
This represents any Component that has ever existed in a LearningPackage.
@@ -69,6 +65,10 @@ class Component(PublishableEntityMixin):
Make a foreign key to the Component model when you need a stable reference
that will exist for as long as the LearningPackage itself exists.
"""
+ # Tell mypy what type our objects manager has.
+ # It's actually PublishableEntityMixinManager, but that has the exact same
+ # interface as the base manager class.
+ objects: models.Manager[Component]
# This foreign key is technically redundant because we're already locked to
# a single LearningPackage through our publishable_entity relation. However, having
@@ -143,6 +143,10 @@ class ComponentVersion(PublishableEntityVersionMixin):
This holds the content using a M:M relationship with RawContent via
ComponentVersionRawContent.
"""
+ # Tell mypy what type our objects manager has.
+ # It's actually PublishableEntityVersionMixinManager, but that has the exact
+ # same interface as the base manager class.
+ objects: models.Manager[ComponentVersion]
# This is technically redundant, since we can get this through
# publishable_entity_version.publishable.component, but this is more
diff --git a/openedx_learning/core/contents/admin.py b/openedx_learning/core/contents/admin.py
index 81230d3d2..0ce7ed617 100644
--- a/openedx_learning/core/contents/admin.py
+++ b/openedx_learning/core/contents/admin.py
@@ -1,13 +1,19 @@
+"""
+Django admin for contents models
+"""
from django.contrib import admin
from django.utils.html import format_html
-from .models import RawContent
-
from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin
+from .models import RawContent
+
@admin.register(RawContent)
class RawContentAdmin(ReadOnlyModelAdmin):
+ """
+ Django admin for RawContent model
+ """
list_display = [
"hash_digest",
"file_link",
diff --git a/openedx_learning/core/contents/api.py b/openedx_learning/core/contents/api.py
index 282bb820f..fd4e069ad 100644
--- a/openedx_learning/core/contents/api.py
+++ b/openedx_learning/core/contents/api.py
@@ -4,18 +4,29 @@
Please look at the models.py file for more information about the kinds of data
are stored in this app.
"""
+from __future__ import annotations
+
import codecs
+from datetime import datetime
from django.core.files.base import ContentFile
from django.db.transaction import atomic
from openedx_learning.lib.fields import create_hash_digest
+
from .models import RawContent, TextContent
def create_raw_content(
- learning_package_id, data_bytes, mime_type, created, hash_digest=None
-):
+ learning_package_id: int,
+ data_bytes: bytes,
+ mime_type: str,
+ created: datetime,
+ hash_digest: str | None = None,
+) -> RawContent:
+ """
+ Create a new RawContent instance and persist it to storage.
+ """
hash_digest = hash_digest or create_hash_digest(data_bytes)
raw_content = RawContent.objects.create(
learning_package_id=learning_package_id,
@@ -31,8 +42,11 @@ def create_raw_content(
return raw_content
-def create_text_from_raw_content(raw_content, encoding="utf-8-sig"):
- text = codecs.decode(raw_content.file.open().read(), "utf-8-sig")
+def create_text_from_raw_content(raw_content: RawContent, encoding="utf-8-sig") -> TextContent:
+ """
+ Create a new TextContent instance for the given RawContent.
+ """
+ text = codecs.decode(raw_content.file.open().read(), encoding)
return TextContent.objects.create(
raw_content=raw_content,
text=text,
@@ -41,37 +55,49 @@ def create_text_from_raw_content(raw_content, encoding="utf-8-sig"):
def get_or_create_raw_content(
- learning_package_id, data_bytes, mime_type, created, hash_digest=None
-):
+ learning_package_id: int,
+ data_bytes: bytes,
+ mime_type: str,
+ created: datetime,
+ hash_digest: str | None = None,
+) -> tuple[RawContent, bool]:
+ """
+ Get the RawContent in the given learning package with the specified data,
+ or create it if it doesn't exist.
+ """
hash_digest = hash_digest or create_hash_digest(data_bytes)
try:
raw_content = RawContent.objects.get(
learning_package_id=learning_package_id, hash_digest=hash_digest
)
- created = False
+ was_created = False
except RawContent.DoesNotExist:
raw_content = create_raw_content(
learning_package_id, data_bytes, mime_type, created, hash_digest
)
- created = True
+ was_created = True
- return raw_content, created
+ return raw_content, was_created
def get_or_create_text_content_from_bytes(
- learning_package_id,
- data_bytes,
- mime_type,
- created,
- hash_digest=None,
- encoding="utf-8-sig",
+ learning_package_id: int,
+ data_bytes: bytes,
+ mime_type: str,
+ created: datetime,
+ hash_digest: str | None = None,
+ encoding: str = "utf-8-sig",
):
+ """
+ Get the TextContent in the given learning package with the specified data,
+ or create it if it doesn't exist.
+ """
with atomic():
raw_content, rc_created = get_or_create_raw_content(
- learning_package_id, data_bytes, mime_type, created, hash_digest=None
+ learning_package_id, data_bytes, mime_type, created, hash_digest
)
if rc_created or not hasattr(raw_content, "text_content"):
- text = codecs.decode(data_bytes, "utf-8-sig")
+ text = codecs.decode(data_bytes, encoding)
text_content = TextContent.objects.create(
raw_content=raw_content,
text=text,
diff --git a/openedx_learning/core/contents/apps.py b/openedx_learning/core/contents/apps.py
index 4c736b812..97cc82733 100644
--- a/openedx_learning/core/contents/apps.py
+++ b/openedx_learning/core/contents/apps.py
@@ -1,3 +1,6 @@
+"""
+Django metadata for the Contents Django application.
+"""
from django.apps import AppConfig
diff --git a/openedx_learning/core/contents/migrations/0001_initial.py b/openedx_learning/core/contents/migrations/0001_initial.py
index 507056fce..20b6b01cd 100644
--- a/openedx_learning/core/contents/migrations/0001_initial.py
+++ b/openedx_learning/core/contents/migrations/0001_initial.py
@@ -1,8 +1,9 @@
# Generated by Django 3.2.19 on 2023-06-15 14:43
import django.core.validators
-from django.db import migrations, models
import django.db.models.deletion
+from django.db import migrations, models
+
import openedx_learning.lib.fields
import openedx_learning.lib.validators
diff --git a/openedx_learning/core/contents/models.py b/openedx_learning/core/contents/models.py
index 6794f1b23..2da7ebfe3 100644
--- a/openedx_learning/core/contents/models.py
+++ b/openedx_learning/core/contents/models.py
@@ -3,22 +3,22 @@
the simplest building blocks to store data with. They need to be composed into
more intelligent data models to be useful.
"""
-from django.db import models
from django.conf import settings
from django.core.files.storage import default_storage
from django.core.validators import MaxValueValidator
+from django.db import models
from openedx_learning.lib.fields import (
+ MultiCollationTextField,
case_insensitive_char_field,
hash_field,
manual_date_time_field,
- MultiCollationTextField,
)
from ..publishing.models import LearningPackage
-class RawContent(models.Model):
+class RawContent(models.Model): # type: ignore[django-manager-missing]
"""
This is the most basic piece of raw content data, with no version metadata.
@@ -104,7 +104,7 @@ class RawContent(models.Model):
# models that offer better latency guarantees.
file = models.FileField(
null=True,
- storage=settings.OPENEDX_LEARNING.get("STORAGE", default_storage),
+ storage=settings.OPENEDX_LEARNING.get("STORAGE", default_storage), # type: ignore
)
class Meta:
@@ -180,7 +180,6 @@ class TextContent(models.Model):
text = MultiCollationTextField(
blank=True,
max_length=MAX_TEXT_LENGTH,
-
# We don't really expect to ever sort by the text column. This is here
# primarily to force the column to be created as utf8mb4 on MySQL. I'm
# using the binary collation because it's a little cheaper/faster.
diff --git a/openedx_learning/core/publishing/admin.py b/openedx_learning/core/publishing/admin.py
index 9ce6a9ef6..56c59d28f 100644
--- a/openedx_learning/core/publishing/admin.py
+++ b/openedx_learning/core/publishing/admin.py
@@ -1,17 +1,20 @@
+"""
+Django admin for publishing models
+"""
+from __future__ import annotations
+
from django.contrib import admin
from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin
-from .models import (
- LearningPackage,
- PublishableEntity,
- Published,
- PublishLog,
- PublishLogRecord,
-)
+
+from .models import LearningPackage, PublishableEntity, Published, PublishLog, PublishLogRecord
@admin.register(LearningPackage)
class LearningPackageAdmin(ReadOnlyModelAdmin):
+ """
+ Read-only admin for LearningPackage model
+ """
fields = ["key", "title", "uuid", "created", "updated"]
readonly_fields = ["key", "title", "uuid", "created", "updated"]
list_display = ["key", "title", "uuid", "created", "updated"]
@@ -19,13 +22,16 @@ class LearningPackageAdmin(ReadOnlyModelAdmin):
class PublishLogRecordTabularInline(admin.TabularInline):
+ """
+ Inline read-only tabular view for PublishLogRecords
+ """
model = PublishLogRecord
- fields = [
+ fields = (
"entity",
"title",
"old_version_num",
"new_version_num",
- ]
+ )
readonly_fields = fields
def get_queryset(self, request):
@@ -43,6 +49,9 @@ def new_version_num(self, pl_record: PublishLogRecord):
return pl_record.new_version.version_num
def title(self, pl_record: PublishLogRecord):
+ """
+ Get the title to display for the PublishLogRecord
+ """
if pl_record.new_version:
return pl_record.new_version.title
if pl_record.old_version:
@@ -52,9 +61,12 @@ def title(self, pl_record: PublishLogRecord):
@admin.register(PublishLog)
class PublishLogAdmin(ReadOnlyModelAdmin):
+ """
+ Read-only admin view for PublishLog
+ """
inlines = [PublishLogRecordTabularInline]
- fields = ["uuid", "learning_package", "published_at", "published_by", "message"]
+ fields = ("uuid", "learning_package", "published_at", "published_by", "message")
readonly_fields = fields
list_display = fields
list_filter = ["learning_package"]
@@ -62,7 +74,10 @@ class PublishLogAdmin(ReadOnlyModelAdmin):
@admin.register(PublishableEntity)
class PublishableEntityAdmin(ReadOnlyModelAdmin):
- fields = [
+ """
+ Read-only admin view for Publishable Entities
+ """
+ fields = (
"key",
"draft_version",
"published_version",
@@ -70,7 +85,7 @@ class PublishableEntityAdmin(ReadOnlyModelAdmin):
"learning_package",
"created",
"created_by",
- ]
+ )
readonly_fields = fields
list_display = fields
list_filter = ["learning_package"]
@@ -91,7 +106,10 @@ def published_version(self, entity):
@admin.register(Published)
class PublishedAdmin(ReadOnlyModelAdmin):
- fields = ["entity", "version_num", "previous", "published_at", "message"]
+ """
+ Read-only admin view for Published model
+ """
+ fields = ("entity", "version_num", "previous", "published_at", "message")
list_display = fields
def get_queryset(self, request):
@@ -108,6 +126,9 @@ def version_num(self, published_obj):
return published_obj.version.version_num
def previous(self, published_obj):
+ """
+ Determine what to show in the "Previous" field
+ """
old_version = published_obj.publish_log_record.old_version
# if there was no previous old version, old version is None
if not old_version:
diff --git a/openedx_learning/core/publishing/api.py b/openedx_learning/core/publishing/api.py
index 52ac7a90e..6a425b8cc 100644
--- a/openedx_learning/core/publishing/api.py
+++ b/openedx_learning/core/publishing/api.py
@@ -4,27 +4,28 @@
Please look at the models.py file for more information about the kinds of data
are stored in this app.
"""
+from __future__ import annotations
+
from datetime import datetime, timezone
-from typing import Optional
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import F, QuerySet
from django.db.transaction import atomic
+from .model_mixins import PublishableContentModelRegistry, PublishableEntityMixin, PublishableEntityVersionMixin
from .models import (
Draft,
LearningPackage,
+ PublishableEntity,
+ PublishableEntityVersion,
Published,
PublishLog,
PublishLogRecord,
- PublishableEntity,
- PublishableEntityVersion,
)
-from .model_mixins import PublishableContentModelRegistry
def create_learning_package(
- key: str, title: str, created: Optional[datetime] = None
+ key: str, title: str, created: datetime | None = None
) -> LearningPackage:
"""
Create a new LearningPackage.
@@ -50,7 +51,13 @@ def create_learning_package(
return package
-def create_publishable_entity(learning_package_id, key, created, created_by):
+def create_publishable_entity(
+ learning_package_id: int,
+ key: str,
+ created: datetime,
+ # User ID who created this
+ created_by: int | None,
+) -> PublishableEntity:
"""
Create a PublishableEntity.
@@ -61,13 +68,17 @@ def create_publishable_entity(learning_package_id, key, created, created_by):
learning_package_id=learning_package_id,
key=key,
created=created,
- created_by=created_by,
+ created_by_id=created_by,
)
def create_publishable_entity_version(
- entity_id, version_num, title, created, created_by
-):
+ entity_id: int,
+ version_num: int,
+ title: str,
+ created: datetime,
+ created_by: int | None,
+) -> PublishableEntityVersion:
"""
Create a PublishableEntityVersion.
@@ -93,11 +104,14 @@ def learning_package_exists(key: str) -> bool:
"""
Check whether a LearningPackage with a particular key exists.
"""
- LearningPackage.objects.filter(key=key).exists()
+ return LearningPackage.objects.filter(key=key).exists()
def publish_all_drafts(
- learning_package_id, message="", published_at=None, published_by=None
+ learning_package_id: int,
+ message="",
+ published_at: datetime | None = None,
+ published_by: int | None = None
):
"""
Publish everything that is a Draft and is not already published.
@@ -116,8 +130,8 @@ def publish_from_drafts(
learning_package_id: int, # LearningPackage.id
draft_qset: QuerySet,
message: str = "",
- published_at: Optional[datetime] = None,
- published_by: Optional[int] = None, # User.id
+ published_at: datetime | None = None,
+ published_by: int | None = None, # User.id
) -> PublishLog:
"""
Publish the rows in the ``draft_model_qsets`` args passed in.
@@ -131,7 +145,7 @@ def publish_from_drafts(
learning_package_id=learning_package_id,
message=message,
published_at=published_at,
- published_by=published_by,
+ published_by_id=published_by,
)
publish_log.full_clean()
publish_log.save(force_insert=True)
@@ -166,7 +180,10 @@ def publish_from_drafts(
return publish_log
-def register_content_models(content_model_cls, content_version_model_cls):
+def register_content_models(
+ content_model_cls: type[PublishableEntityMixin],
+ content_version_model_cls: type[PublishableEntityVersionMixin],
+):
"""
Register what content model maps to what content version model.
diff --git a/openedx_learning/core/publishing/migrations/0001_initial.py b/openedx_learning/core/publishing/migrations/0001_initial.py
index 3f41cd45a..d7663748a 100644
--- a/openedx_learning/core/publishing/migrations/0001_initial.py
+++ b/openedx_learning/core/publishing/migrations/0001_initial.py
@@ -1,12 +1,14 @@
# Generated by Django 3.2.19 on 2023-06-15 14:43
-from django.conf import settings
+import uuid
+
import django.core.validators
-from django.db import migrations, models
import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
import openedx_learning.lib.fields
import openedx_learning.lib.validators
-import uuid
class Migration(migrations.Migration):
diff --git a/openedx_learning/core/publishing/model_mixins.py b/openedx_learning/core/publishing/model_mixins.py
index 1623bb256..02cc7f200 100644
--- a/openedx_learning/core/publishing/model_mixins.py
+++ b/openedx_learning/core/publishing/model_mixins.py
@@ -1,13 +1,15 @@
"""
Helper mixin classes for content apps that want to use the publishing app.
"""
+from __future__ import annotations
+
from functools import cached_property
from django.core.exceptions import ImproperlyConfigured
from django.db import models
from django.db.models.query import QuerySet
-from .models import Draft, Published, PublishableEntity, PublishableEntityVersion
+from .models import Draft, PublishableEntity, PublishableEntityVersion, Published
class PublishableEntityMixin(models.Model):
@@ -25,7 +27,7 @@ class PublishableEntityMixinManager(models.Manager):
def get_queryset(self) -> QuerySet:
return super().get_queryset().select_related("publishable_entity")
- objects = PublishableEntityMixinManager()
+ objects: models.Manager[PublishableEntityMixin] = PublishableEntityMixinManager()
publishable_entity = models.OneToOneField(
PublishableEntity, on_delete=models.CASCADE, primary_key=True
@@ -216,7 +218,7 @@ def get_queryset(self) -> QuerySet:
)
)
- objects = PublishableEntityVersionMixinManager()
+ objects: models.Manager[PublishableEntityVersionMixin] = PublishableEntityVersionMixinManager()
publishable_entity_version = models.OneToOneField(
PublishableEntityVersion, on_delete=models.CASCADE, primary_key=True
@@ -247,11 +249,15 @@ class PublishableContentModelRegistry:
This class tracks content models built on PublishableEntity(Version).
"""
- _unversioned_to_versioned = {}
- _versioned_to_unversioned = {}
+ _unversioned_to_versioned: dict[type[PublishableEntityMixin], type[PublishableEntityVersionMixin]] = {}
+ _versioned_to_unversioned: dict[type[PublishableEntityVersionMixin], type[PublishableEntityMixin]] = {}
@classmethod
- def register(cls, content_model_cls, content_version_model_cls):
+ def register(
+ cls,
+ content_model_cls: type[PublishableEntityMixin],
+ content_version_model_cls: type[PublishableEntityVersionMixin],
+ ):
"""
Register what content model maps to what content version model.
diff --git a/openedx_learning/core/publishing/models.py b/openedx_learning/core/publishing/models.py
index 78f19b4ba..7cbeef87c 100644
--- a/openedx_learning/core/publishing/models.py
+++ b/openedx_learning/core/publishing/models.py
@@ -11,10 +11,9 @@
* Managing reverts.
* Storing and querying publish history.
"""
-from django.db import models
-
from django.conf import settings
from django.core.validators import MinValueValidator
+from django.db import models
from openedx_learning.lib.fields import (
case_insensitive_char_field,
@@ -24,13 +23,12 @@
)
-class LearningPackage(models.Model):
+class LearningPackage(models.Model): # type: ignore[django-manager-missing]
"""
Top level container for a grouping of authored content.
Each PublishableEntity belongs to exactly one LearningPackage.
"""
-
uuid = immutable_uuid_field()
key = key_field()
title = case_insensitive_char_field(max_length=500, blank=False)
diff --git a/openedx_learning/lib/collations.py b/openedx_learning/lib/collations.py
index 561b1f316..0019f887a 100644
--- a/openedx_learning/lib/collations.py
+++ b/openedx_learning/lib/collations.py
@@ -15,7 +15,7 @@ class MultiCollationMixin:
they're the only Field types that store text data.
"""
- def __init__(self, *args, db_collations=None, db_collation=None, **kwargs):
+ def __init__(self, *args, db_collations=None, db_collation=None, **kwargs): # pylint: disable=unused-argument
"""
Init like any field but add db_collations and disallow db_collation
@@ -77,7 +77,6 @@ def db_collation(self, value):
This can be removed when we move to Django 4.2.
"""
- pass
def db_parameters(self, connection):
"""
diff --git a/openedx_learning/lib/fields.py b/openedx_learning/lib/fields.py
index 0e231e3a6..b239eb8c0 100644
--- a/openedx_learning/lib/fields.py
+++ b/openedx_learning/lib/fields.py
@@ -7,9 +7,9 @@
We have helpers to make case sensitivity consistent across backends. MySQL is
case-insensitive by default, SQLite and Postgres are case-sensitive.
-
-
"""
+from __future__ import annotations
+
import hashlib
import uuid
@@ -19,11 +19,11 @@
from .validators import validate_utc_datetime
-def create_hash_digest(data_bytes):
+def create_hash_digest(data_bytes: bytes) -> str:
return hashlib.blake2b(data_bytes, digest_size=20).hexdigest()
-def case_insensitive_char_field(**kwargs):
+def case_insensitive_char_field(**kwargs) -> MultiCollationCharField:
"""
Return a case-insensitive ``MultiCollationCharField``.
@@ -53,7 +53,7 @@ def case_insensitive_char_field(**kwargs):
return MultiCollationCharField(**final_kwargs)
-def case_sensitive_char_field(**kwargs):
+def case_sensitive_char_field(**kwargs) -> MultiCollationCharField:
"""
Return a case-sensitive ``MultiCollationCharField``.
@@ -79,7 +79,7 @@ def case_sensitive_char_field(**kwargs):
return MultiCollationCharField(**final_kwargs)
-def immutable_uuid_field():
+def immutable_uuid_field() -> models.UUIDField:
"""
Stable, randomly-generated UUIDs.
@@ -97,7 +97,7 @@ def immutable_uuid_field():
)
-def key_field():
+def key_field() -> MultiCollationCharField:
"""
Externally created Identifier fields.
@@ -111,7 +111,7 @@ def key_field():
return case_sensitive_char_field(max_length=500, blank=False)
-def hash_field():
+def hash_field() -> models.CharField:
"""
Holds a hash digest meant to identify a piece of content.
@@ -130,7 +130,7 @@ def hash_field():
)
-def manual_date_time_field():
+def manual_date_time_field() -> models.DateTimeField:
"""
DateTimeField that does not auto-generate values.
@@ -159,7 +159,7 @@ def manual_date_time_field():
],
)
-
+
class MultiCollationCharField(MultiCollationMixin, models.CharField):
"""
CharField subclass with per-database-vendor collation settings.
@@ -172,7 +172,6 @@ class MultiCollationCharField(MultiCollationMixin, models.CharField):
PostgreSQL. Even MariaDB is starting to diverge from MySQL in terms of what
collations are supported.
"""
- pass
class MultiCollationTextField(MultiCollationMixin, models.TextField):
@@ -181,6 +180,5 @@ class MultiCollationTextField(MultiCollationMixin, models.TextField):
We don't ever really want to _sort_ by a TextField, but setting a collation
forces the compatible charset to be set in MySQL, and that's the part that
- matters for our purposes. So for example, if you set
+ matters for our purposes.
"""
- pass
diff --git a/openedx_learning/lib/validators.py b/openedx_learning/lib/validators.py
index 035a35540..a59cfd1ad 100644
--- a/openedx_learning/lib/validators.py
+++ b/openedx_learning/lib/validators.py
@@ -1,3 +1,6 @@
+"""
+Useful validation methods
+"""
from datetime import datetime, timezone
from django.core.exceptions import ValidationError
diff --git a/openedx_learning/py.typed b/openedx_learning/py.typed
new file mode 100644
index 000000000..e69de29bb
diff --git a/openedx_learning/rest_api/apps.py b/openedx_learning/rest_api/apps.py
index fe7f93f4c..dd3da9955 100644
--- a/openedx_learning/rest_api/apps.py
+++ b/openedx_learning/rest_api/apps.py
@@ -1,3 +1,6 @@
+"""
+Django metadata for the Learning Core REST API app
+"""
from django.apps import AppConfig
diff --git a/openedx_learning/rest_api/urls.py b/openedx_learning/rest_api/urls.py
index edc533ff9..4c7fe274f 100644
--- a/openedx_learning/rest_api/urls.py
+++ b/openedx_learning/rest_api/urls.py
@@ -1,3 +1,6 @@
+"""
+URLs for the Learning Core REST API
+"""
from django.urls import include, path
urlpatterns = [path("v1/", include("openedx_learning.rest_api.v1.urls"))]
diff --git a/openedx_learning/rest_api/v1/components.py b/openedx_learning/rest_api/v1/components.py
index 571753b3f..d522f03ad 100644
--- a/openedx_learning/rest_api/v1/components.py
+++ b/openedx_learning/rest_api/v1/components.py
@@ -7,8 +7,11 @@
class ComponentViewSet(viewsets.ViewSet):
+ """
+ Example endpoints for a Components REST API. Not implemented.
+ """
def list(self, request):
- items = Component.objects.all()
+ _items = Component.objects.all()
raise NotImplementedError
def retrieve(self, request, pk=None):
diff --git a/openedx_learning/rest_api/v1/urls.py b/openedx_learning/rest_api/v1/urls.py
index 3b1a66c35..b1f5e3768 100644
--- a/openedx_learning/rest_api/v1/urls.py
+++ b/openedx_learning/rest_api/v1/urls.py
@@ -1,3 +1,6 @@
+"""
+URLs for the Learning Core REST API v1
+"""
from rest_framework.routers import DefaultRouter
from . import components
diff --git a/openedx_tagging/__init__.py b/openedx_tagging/__init__.py
index 6b99f0677..ea956e000 100644
--- a/openedx_tagging/__init__.py
+++ b/openedx_tagging/__init__.py
@@ -1 +1,3 @@
-"""Open edX Tagging app."""
+"""
+Open edX Tagging app.
+"""
diff --git a/openedx_tagging/core/tagging/admin.py b/openedx_tagging/core/tagging/admin.py
index 91b1d753d..5be444cfc 100644
--- a/openedx_tagging/core/tagging/admin.py
+++ b/openedx_tagging/core/tagging/admin.py
@@ -1,4 +1,6 @@
-""" Tagging app admin """
+"""
+Tagging app admin
+"""
from django.contrib import admin
from .models import ObjectTag, Tag, Taxonomy
diff --git a/openedx_tagging/core/tagging/api.py b/openedx_tagging/core/tagging/api.py
index 0ccb46883..e0295df59 100644
--- a/openedx_tagging/core/tagging/api.py
+++ b/openedx_tagging/core/tagging/api.py
@@ -10,7 +10,9 @@
Please look at the models.py file for more information about the kinds of data
are stored in this app.
"""
-from typing import Iterator, List, Type, Union
+from __future__ import annotations
+
+from typing import Iterator
from django.db.models import QuerySet
from django.utils.translation import gettext_lazy as _
@@ -20,19 +22,19 @@
def create_taxonomy(
name: str,
- description: str = None,
+ description: str | None = None,
enabled=True,
required=False,
allow_multiple=False,
allow_free_text=False,
- taxonomy_class: Type = None,
+ taxonomy_class: type[Taxonomy] | None = None,
) -> Taxonomy:
"""
Creates, saves, and returns a new Taxonomy with the given attributes.
"""
taxonomy = Taxonomy(
name=name,
- description=description,
+ description=description or "",
enabled=enabled,
required=required,
allow_multiple=allow_multiple,
@@ -44,7 +46,7 @@ def create_taxonomy(
return taxonomy.cast()
-def get_taxonomy(id: int) -> Union[Taxonomy, None]:
+def get_taxonomy(id: int) -> Taxonomy | None:
"""
Returns a Taxonomy cast to the appropriate subclass which has the given ID.
"""
@@ -52,7 +54,7 @@ def get_taxonomy(id: int) -> Union[Taxonomy, None]:
return taxonomy.cast() if taxonomy else None
-def get_taxonomies(enabled=True) -> QuerySet:
+def get_taxonomies(enabled=True) -> QuerySet[Taxonomy]:
"""
Returns a queryset containing the enabled taxonomies, sorted by name.
@@ -68,7 +70,7 @@ def get_taxonomies(enabled=True) -> QuerySet:
return queryset.filter(enabled=enabled)
-def get_tags(taxonomy: Taxonomy) -> List[Tag]:
+def get_tags(taxonomy: Taxonomy) -> list[Tag]:
"""
Returns a list of predefined tags for the given taxonomy.
@@ -77,7 +79,7 @@ def get_tags(taxonomy: Taxonomy) -> List[Tag]:
return taxonomy.cast().get_tags()
-def resync_object_tags(object_tags: QuerySet = None) -> int:
+def resync_object_tags(object_tags: QuerySet | None = None) -> int:
"""
Reconciles ObjectTag entries with any changes made to their associated taxonomies and tags.
@@ -96,25 +98,25 @@ def resync_object_tags(object_tags: QuerySet = None) -> int:
def get_object_tags(
- object_id: str, taxonomy_id: str = None
-) -> QuerySet:
+ object_id: str,
+ taxonomy_id: str | None = None
+) -> QuerySet[ObjectTag]:
"""
Returns a Queryset of object tags for a given object.
Pass taxonomy to limit the returned object_tags to a specific taxonomy.
"""
- taxonomy = get_taxonomy(taxonomy_id)
- ObjectTagClass = taxonomy.object_tag_class if taxonomy else ObjectTag
+ ObjectTagClass = ObjectTag
+ extra_filters = {}
+ if taxonomy_id is not None:
+ taxonomy = Taxonomy.objects.get(pk=taxonomy_id)
+ ObjectTagClass = taxonomy.object_tag_class
+ extra_filters["taxonomy_id"] = taxonomy_id
tags = (
- ObjectTagClass.objects.filter(
- object_id=object_id,
- )
+ ObjectTagClass.objects.filter(object_id=object_id, **extra_filters)
.select_related("tag", "taxonomy")
.order_by("id")
)
- if taxonomy:
- tags = tags.filter(taxonomy=taxonomy)
-
return tags
@@ -133,9 +135,9 @@ def delete_object_tags(object_id: str):
def tag_object(
taxonomy: Taxonomy,
- tags: List,
+ tags: list[str],
object_id: str,
-) -> List[ObjectTag]:
+) -> list[ObjectTag]:
"""
Replaces the existing ObjectTag entries for the given taxonomy + object_id with the given list of tags.
@@ -151,7 +153,7 @@ def tag_object(
def autocomplete_tags(
taxonomy: Taxonomy,
search: str,
- object_id: str = None,
+ object_id: str | None = None,
object_tags_only=True,
) -> QuerySet:
"""
diff --git a/openedx_tagging/core/tagging/import_export/actions.py b/openedx_tagging/core/tagging/import_export/actions.py
index da757cf4b..409194277 100644
--- a/openedx_tagging/core/tagging/import_export/actions.py
+++ b/openedx_tagging/core/tagging/import_export/actions.py
@@ -1,12 +1,12 @@
"""
Actions for import tags
"""
-from typing import List
+from __future__ import annotations
-from django.utils.translation import gettext_lazy as _
+from django.utils.translation import gettext as _
-from ..models import Taxonomy, Tag
-from .exceptions import ImportActionError, ImportActionConflict
+from ..models import Tag, Taxonomy
+from .exceptions import ImportActionConflict, ImportActionError
class ImportAction:
@@ -40,10 +40,10 @@ def __init__(self, taxonomy: Taxonomy, tag, index: int):
self.tag = tag
self.index = index
- def __repr__(self):
+ def __repr__(self) -> str:
return str(_(f"Action {self.name} (index={self.index},id={self.tag.id})"))
- def __str__(self):
+ def __str__(self) -> str:
return self.__repr__()
@classmethod
@@ -55,20 +55,20 @@ def applies_for(cls, taxonomy: Taxonomy, tag) -> bool:
"""
raise NotImplementedError
- def validate(self, indexed_actions) -> List[ImportActionError]:
+ def validate(self, indexed_actions) -> list[ImportActionError]:
"""
Implement this to find inconsistencies with tags in the
database or with previous actions.
"""
raise NotImplementedError
- def execute(self):
+ def execute(self) -> None:
"""
Implement this to execute the action.
"""
raise NotImplementedError
- def _get_tag(self):
+ def _get_tag(self) -> Tag:
"""
Returns the respective tag of this actions
"""
@@ -90,7 +90,7 @@ def _search_action(
return None
- def _validate_parent(self, indexed_actions) -> ImportActionError:
+ def _validate_parent(self, indexed_actions) -> ImportActionError | None:
"""
Helper method to validate that the parent tag has already been defined.
"""
@@ -110,8 +110,9 @@ def _validate_parent(self, indexed_actions) -> ImportActionError:
"You need to add parent before the child in your file."
),
)
+ return None
- def _validate_value(self, indexed_actions):
+ def _validate_value(self, indexed_actions) -> ImportActionError | None:
"""
Check for value duplicates in the models and in previous create/rename
actions
@@ -151,6 +152,7 @@ def _validate_value(self, indexed_actions):
conflict_action_index=action.index,
message=_("Duplicated tag value."),
)
+ return None
class CreateTag(ImportAction):
@@ -169,7 +171,7 @@ class CreateTag(ImportAction):
name = "create"
- def __str__(self):
+ def __str__(self) -> str:
return str(
_(
"Create a new tag with values "
@@ -189,7 +191,7 @@ def applies_for(cls, taxonomy: Taxonomy, tag) -> bool:
except Tag.DoesNotExist:
return True
- def _validate_id(self, indexed_actions):
+ def _validate_id(self, indexed_actions) -> ImportActionError | None:
"""
Check for id duplicates in previous create actions
"""
@@ -201,8 +203,9 @@ def _validate_id(self, indexed_actions):
conflict_action_index=action.index,
message=_("Duplicated external_id tag."),
)
+ return None
- def validate(self, indexed_actions) -> List[ImportActionError]:
+ def validate(self, indexed_actions) -> list[ImportActionError]:
"""
Validates the creation action
"""
@@ -226,7 +229,7 @@ def validate(self, indexed_actions) -> List[ImportActionError]:
return errors
- def execute(self):
+ def execute(self) -> None:
"""
Creates a Tag
"""
@@ -255,7 +258,7 @@ class UpdateParentTag(ImportAction):
name = "update_parent"
- def __str__(self):
+ def __str__(self) -> str:
taxonomy_tag = self._get_tag()
if not taxonomy_tag.parent:
from_str = _("from empty parent")
@@ -283,7 +286,7 @@ def applies_for(cls, taxonomy: Taxonomy, tag) -> bool:
except Tag.DoesNotExist:
return False
- def validate(self, indexed_actions) -> List[ImportActionError]:
+ def validate(self, indexed_actions) -> list[ImportActionError]:
"""
Validates the update parent action
"""
@@ -297,7 +300,7 @@ def validate(self, indexed_actions) -> List[ImportActionError]:
return errors
- def execute(self):
+ def execute(self) -> None:
"""
Updates the parent of a tag
"""
@@ -322,7 +325,7 @@ class RenameTag(ImportAction):
name = "rename"
- def __str__(self):
+ def __str__(self) -> str:
taxonomy_tag = self._get_tag()
return str(
_(
@@ -342,7 +345,7 @@ def applies_for(cls, taxonomy: Taxonomy, tag) -> bool:
except Tag.DoesNotExist:
return False
- def validate(self, indexed_actions) -> List[ImportActionError]:
+ def validate(self, indexed_actions) -> list[ImportActionError]:
"""
Validates the rename action
"""
@@ -355,7 +358,7 @@ def validate(self, indexed_actions) -> List[ImportActionError]:
return errors
- def execute(self):
+ def execute(self) -> None:
"""
Rename a tag
"""
@@ -373,7 +376,7 @@ class DeleteTag(ImportAction):
Does not require validations
"""
- def __str__(self):
+ def __str__(self) -> str:
taxonomy_tag = self._get_tag()
return str(_(f"Delete tag (external_id={taxonomy_tag.external_id})"))
@@ -387,14 +390,14 @@ def applies_for(cls, taxonomy: Taxonomy, tag) -> bool:
"""
return False
- def validate(self, indexed_actions) -> List[ImportActionError]:
+ def validate(self, indexed_actions) -> list[ImportActionError]:
"""
No validations necessary
"""
# TODO: Will it be necessary to check if this tag has children?
return []
- def execute(self):
+ def execute(self) -> None:
"""
Delete a tag
"""
@@ -411,7 +414,7 @@ class WithoutChanges(ImportAction):
name = "without_changes"
- def __str__(self):
+ def __str__(self) -> str:
return str(_(f"No changes needed for tag (external_id={self.tag.id})"))
@classmethod
@@ -421,13 +424,13 @@ def applies_for(cls, taxonomy: Taxonomy, tag) -> bool:
"""
return False
- def validate(self, indexed_actions) -> List[ImportActionError]:
+ def validate(self, indexed_actions) -> list[ImportActionError]:
"""
No validations necessary
"""
return []
- def execute(self):
+ def execute(self) -> None:
"""
Do nothing
"""
diff --git a/openedx_tagging/core/tagging/import_export/api.py b/openedx_tagging/core/tagging/import_export/api.py
index 4e26854ed..80f01ad64 100644
--- a/openedx_tagging/core/tagging/import_export/api.py
+++ b/openedx_tagging/core/tagging/import_export/api.py
@@ -42,13 +42,15 @@
(ex. server crash)
- Join/reduce actions on TagImportPlan. See `generate_actions()`
"""
+from __future__ import annotations
+
from io import BytesIO
from django.utils.translation import gettext_lazy as _
-from ..models import Taxonomy, TagImportTask, TagImportTaskState
-from .parsers import get_parser, ParserFormat
+from ..models import TagImportTask, TagImportTaskState, Taxonomy
from .import_plan import TagImportPlan, TagImportTask
+from .parsers import ParserFormat, get_parser
def import_tags(
@@ -124,7 +126,9 @@ def get_last_import_status(taxonomy: Taxonomy) -> TagImportTaskState:
Get status of the last import task of the given taxonomy
"""
task = _get_last_import_task(taxonomy)
- return task.status
+ if task is None:
+ raise ValueError("No import task was created yet.")
+ return TagImportTaskState(task.status)
def get_last_import_log(taxonomy: Taxonomy) -> str:
@@ -132,6 +136,8 @@ def get_last_import_log(taxonomy: Taxonomy) -> str:
Get logs of the last import task of the given taxonomy
"""
task = _get_last_import_task(taxonomy)
+ if task is None:
+ raise ValueError("No import task was created yet.")
return task.log
@@ -158,7 +164,7 @@ def _check_unique_import_task(taxonomy: Taxonomy) -> bool:
)
-def _get_last_import_task(taxonomy: Taxonomy) -> TagImportTask:
+def _get_last_import_task(taxonomy: Taxonomy) -> TagImportTask | None:
"""
Get the last import task for the given taxonomy
"""
diff --git a/openedx_tagging/core/tagging/import_export/exceptions.py b/openedx_tagging/core/tagging/import_export/exceptions.py
index 2330cae6a..91a6b6f9b 100644
--- a/openedx_tagging/core/tagging/import_export/exceptions.py
+++ b/openedx_tagging/core/tagging/import_export/exceptions.py
@@ -1,7 +1,14 @@
"""
Exceptions for tag import/export actions
"""
-from django.utils.translation import gettext_lazy as _
+from __future__ import annotations
+
+import typing
+
+from django.utils.translation import gettext as _
+
+if typing.TYPE_CHECKING:
+ from .actions import ImportAction
class TagImportError(Exception):
@@ -11,6 +18,7 @@ class TagImportError(Exception):
def __init__(self, message: str = "", **kargs):
self.message = message
+ super().__init__(message, **kargs)
def __str__(self):
return str(self.message)
@@ -24,7 +32,9 @@ class TagParserError(TagImportError):
Base exception for parsers
"""
- def __init__(self, tag, **kargs):
+ def __init__(self, tag: dict | None, **kargs):
+ super().__init__()
+ self.tag = tag
self.message = _(f"Import parser error on {tag}")
@@ -33,7 +43,7 @@ class ImportActionError(TagImportError):
Base exception for actions
"""
- def __init__(self, action: str, tag_id: str, message: str, **kargs):
+ def __init__(self, action: ImportAction, tag_id: str, message: str, **kargs):
self.message = _(
f"Action error in '{action.name}' (#{action.index}): {message}"
)
@@ -46,7 +56,7 @@ class ImportActionConflict(ImportActionError):
def __init__(
self,
- action: str,
+ action: ImportAction,
tag_id: str,
conflict_action_index: int,
message: str,
@@ -63,8 +73,8 @@ class InvalidFormat(TagParserError):
Exception used when there is an error with the format
"""
- def __init__(self, tag: dict, format: str, message: str, **kargs):
- self.tag = tag
+ def __init__(self, tag: dict | None, format: str, message: str, **kargs):
+ super().__init__(tag)
self.message = _(f"Invalid '{format}' format: {message}")
@@ -73,8 +83,8 @@ class FieldJSONError(TagParserError):
Exception used when missing a required field on the .json
"""
- def __init__(self, tag, field, **kargs):
- self.tag = tag
+ def __init__(self, tag: dict | None, field, **kargs):
+ super().__init__(tag)
self.message = _(f"Missing '{field}' field on {tag}")
diff --git a/openedx_tagging/core/tagging/import_export/import_plan.py b/openedx_tagging/core/tagging/import_export/import_plan.py
index 3af3597d6..774afcadb 100644
--- a/openedx_tagging/core/tagging/import_export/import_plan.py
+++ b/openedx_tagging/core/tagging/import_export/import_plan.py
@@ -1,19 +1,13 @@
"""
Classes and functions to create an import plan and execution.
"""
-from attrs import define
-from typing import List, Optional
+from __future__ import annotations
+from attrs import define
from django.db import transaction
-from ..models import Taxonomy, TagImportTask
-from .actions import (
- DeleteTag,
- ImportAction,
- UpdateParentTag,
- WithoutChanges,
- available_actions,
-)
+from ..models import TagImportTask, Taxonomy
+from .actions import DeleteTag, ImportAction, UpdateParentTag, WithoutChanges, available_actions
from .exceptions import ImportActionError
@@ -22,11 +16,10 @@ class TagItem:
"""
Tag representation on the tag import plan
"""
-
id: str
value: str
- index: Optional[int] = 0
- parent_id: Optional[str] = None
+ index: int | None = 0
+ parent_id: str | None = None
class TagImportPlan:
@@ -34,8 +27,8 @@ class TagImportPlan:
Class with functions to build an import plan and excute the plan
"""
- actions: List[ImportAction]
- errors: List[ImportActionError]
+ actions: list[ImportAction]
+ errors: list[ImportActionError]
indexed_actions: dict
actions_dict: dict
taxonomy: Taxonomy
@@ -119,7 +112,7 @@ def _build_delete_actions(self, tags: dict):
def generate_actions(
self,
- tags: List[TagItem],
+ tags: list[TagItem],
replace=False,
):
"""
@@ -182,7 +175,7 @@ def plan(self) -> str:
return result
@transaction.atomic()
- def execute(self, task: TagImportTask = None):
+ def execute(self, task: TagImportTask | None = None):
"""
Executes each action
diff --git a/openedx_tagging/core/tagging/import_export/parsers.py b/openedx_tagging/core/tagging/import_export/parsers.py
index 79171e7e5..1fb714735 100644
--- a/openedx_tagging/core/tagging/import_export/parsers.py
+++ b/openedx_tagging/core/tagging/import_export/parsers.py
@@ -1,24 +1,19 @@
"""
Parsers to import and export tags
"""
+from __future__ import annotations
+
import csv
import json
from enum import Enum
-from io import BytesIO, TextIOWrapper, StringIO
-from typing import List, Tuple
+from io import BytesIO, StringIO, TextIOWrapper
-from django.utils.translation import gettext_lazy as _
+from django.utils.translation import gettext as _
-from .import_plan import TagItem
-from .exceptions import (
- TagParserError,
- InvalidFormat,
- FieldJSONError,
- EmptyJSONField,
- EmptyCSVField,
-)
-from ..models import Taxonomy
from ..api import get_tags
+from ..models import Taxonomy
+from .exceptions import EmptyCSVField, EmptyJSONField, FieldJSONError, InvalidFormat, TagParserError
+from .import_plan import TagItem
class ParserFormat(Enum):
@@ -48,7 +43,7 @@ class Parser:
optional_fields = ["parent_id"]
# Set the format associated to the parser
- format = None
+ format: ParserFormat
# We can change the error when is missing a required field
missing_field_error = TagParserError
# We can change the error when a required field is empty
@@ -57,7 +52,7 @@ class Parser:
inital_row = 1
@classmethod
- def parse_import(cls, file: BytesIO) -> Tuple[List[TagItem], List[TagParserError]]:
+ def parse_import(cls, file: BytesIO) -> tuple[list[TagItem], list[TagParserError]]:
"""
Parse tags in file an returns tags ready for use in TagImportPlan
@@ -85,7 +80,7 @@ def export(cls, taxonomy: Taxonomy) -> str:
return cls._export_data(tags, taxonomy)
@classmethod
- def _load_data(cls, file: BytesIO) -> Tuple[List[dict], List[TagParserError]]:
+ def _load_data(cls, file: BytesIO) -> tuple[list[dict], list[TagParserError]]:
"""
Each parser implements this function according to its format.
This function reads the file and returns a list with the values of each tag.
@@ -96,7 +91,7 @@ def _load_data(cls, file: BytesIO) -> Tuple[List[dict], List[TagParserError]]:
raise NotImplementedError
@classmethod
- def _export_data(cls, tags: List[dict], taxonomy: Taxonomy) -> str:
+ def _export_data(cls, tags: list[dict], taxonomy: Taxonomy) -> str:
"""
Each parser implements this function according to its format.
Returns a string with tags data in the parser format.
@@ -108,7 +103,7 @@ def _export_data(cls, tags: List[dict], taxonomy: Taxonomy) -> str:
raise NotImplementedError
@classmethod
- def _parse_tags(cls, tags_data: dict) -> Tuple[List[TagItem], List[TagParserError]]:
+ def _parse_tags(cls, tags_data: list[dict]) -> tuple[list[TagItem], list[TagParserError]]:
"""
Validate the required fields of each tag.
@@ -161,7 +156,7 @@ def _parse_tags(cls, tags_data: dict) -> Tuple[List[TagItem], List[TagParserErro
return tags, errors
@classmethod
- def _load_tags_for_export(cls, taxonomy: Taxonomy) -> List[dict]:
+ def _load_tags_for_export(cls, taxonomy: Taxonomy) -> list[dict]:
"""
Returns a list of taxonomy's tags in the form of a dictionary
with required and optional fields
@@ -201,12 +196,12 @@ class JSONParser(Parser):
"""
format = ParserFormat.JSON
- missing_field_error = FieldJSONError
- empty_field_error = EmptyJSONField
+ missing_field_error: type[TagParserError] = FieldJSONError
+ empty_field_error: type[TagParserError] = EmptyJSONField
inital_row = 0
@classmethod
- def _load_data(cls, file: BytesIO) -> Tuple[List[dict], List[TagParserError]]:
+ def _load_data(cls, file: BytesIO) -> tuple[list[dict], list[TagParserError]]:
"""
Read a .json file and validates the root structure of the json
"""
@@ -214,11 +209,11 @@ def _load_data(cls, file: BytesIO) -> Tuple[List[dict], List[TagParserError]]:
try:
tags_data = json.load(file)
except json.JSONDecodeError as error:
- return None, [
+ return [], [
InvalidFormat(tag=None, format=cls.format.value, message=str(error))
]
if "tags" not in tags_data:
- return None, [
+ return [], [
InvalidFormat(
tag=None,
format=cls.format.value,
@@ -230,7 +225,7 @@ def _load_data(cls, file: BytesIO) -> Tuple[List[dict], List[TagParserError]]:
return tags_data, []
@classmethod
- def _export_data(cls, tags: List[dict], taxonomy: Taxonomy) -> str:
+ def _export_data(cls, tags: list[dict], taxonomy: Taxonomy) -> str:
"""
Export tags and taxonomy metadata in JSON format
"""
@@ -255,11 +250,11 @@ class CSVParser(Parser):
"""
format = ParserFormat.CSV
- empty_field_error = EmptyCSVField
+ empty_field_error: type[TagParserError] = EmptyCSVField
inital_row = 2
@classmethod
- def _load_data(cls, file: BytesIO) -> Tuple[List[dict], List[TagParserError]]:
+ def _load_data(cls, file: BytesIO) -> tuple[list[dict], list[TagParserError]]:
"""
Read a .csv file and validates the header fields
"""
@@ -267,13 +262,13 @@ def _load_data(cls, file: BytesIO) -> Tuple[List[dict], List[TagParserError]]:
text_tags = TextIOWrapper(file, encoding="utf-8")
csv_reader = csv.DictReader(text_tags)
header_fields = csv_reader.fieldnames
- errors = cls._verify_header(header_fields)
+ errors = cls._verify_header(list(header_fields or []))
if errors:
- return None, errors
+ return [], errors
return list(csv_reader), []
@classmethod
- def _export_data(cls, tags: List[dict], taxonomy: Taxonomy) -> str:
+ def _export_data(cls, tags: list[dict], taxonomy: Taxonomy) -> str:
"""
Export tags in CSV format
@@ -291,11 +286,11 @@ def _export_data(cls, tags: List[dict], taxonomy: Taxonomy) -> str:
return csv_string
@classmethod
- def _verify_header(cls, header_fields: List[str]) -> List[TagParserError]:
+ def _verify_header(cls, header_fields: list[str]) -> list[TagParserError]:
"""
Verify that the header contains the required fields
"""
- errors = []
+ errors: list[TagParserError] = []
for req_field in cls.required_fields:
if req_field not in header_fields:
errors.append(
@@ -312,7 +307,7 @@ def _verify_header(cls, header_fields: List[str]) -> List[TagParserError]:
_parsers = [JSONParser, CSVParser]
-def get_parser(parser_format: ParserFormat) -> Parser:
+def get_parser(parser_format: ParserFormat) -> type[Parser]:
"""
Get the parser for the respective `format`
diff --git a/openedx_tagging/core/tagging/import_export/tasks.py b/openedx_tagging/core/tagging/import_export/tasks.py
index 99d3cd49d..f351ea8ec 100644
--- a/openedx_tagging/core/tagging/import_export/tasks.py
+++ b/openedx_tagging/core/tagging/import_export/tasks.py
@@ -2,9 +2,11 @@
Import and export celery tasks
"""
from io import BytesIO
-from celery import shared_task
+
+from celery import shared_task # type: ignore[import]
import openedx_tagging.core.tagging.import_export.api as import_export_api
+
from ..models import Taxonomy
from .parsers import ParserFormat
diff --git a/openedx_tagging/core/tagging/management/commands/build_language_fixture.py b/openedx_tagging/core/tagging/management/commands/build_language_fixture.py
index 9c1e0a85e..4901ca9fe 100644
--- a/openedx_tagging/core/tagging/management/commands/build_language_fixture.py
+++ b/openedx_tagging/core/tagging/management/commands/build_language_fixture.py
@@ -10,7 +10,7 @@
from django.core.management.base import BaseCommand
-endpoint = "https://pkgstore.datahub.io/core/language-codes/language-codes_json/data/97607046542b532c395cf83df5185246/language-codes_json.json"
+endpoint = "https://pkgstore.datahub.io/core/language-codes/language-codes_json/data/97607046542b532c395cf83df5185246/language-codes_json.json" # noqa
output = "./openedx_tagging/core/tagging/fixtures/language_taxonomy.yaml"
diff --git a/openedx_tagging/core/tagging/migrations/0004_auto_20230723_2001.py b/openedx_tagging/core/tagging/migrations/0004_auto_20230723_2001.py
index 4cca2c417..c96e05209 100644
--- a/openedx_tagging/core/tagging/migrations/0004_auto_20230723_2001.py
+++ b/openedx_tagging/core/tagging/migrations/0004_auto_20230723_2001.py
@@ -1,6 +1,7 @@
# Generated by Django 3.2.19 on 2023-07-24 06:25
from django.db import migrations, models
+
import openedx_learning.lib.fields
diff --git a/openedx_tagging/core/tagging/migrations/0005_language_taxonomy.py b/openedx_tagging/core/tagging/migrations/0005_language_taxonomy.py
index a6d4fd0cf..b6758da11 100644
--- a/openedx_tagging/core/tagging/migrations/0005_language_taxonomy.py
+++ b/openedx_tagging/core/tagging/migrations/0005_language_taxonomy.py
@@ -1,7 +1,7 @@
# Generated by Django 3.2.19 on 2023-07-28 13:33
-from django.db import migrations
from django.core.management import call_command
+from django.db import migrations
def load_language_taxonomy(apps, schema_editor):
diff --git a/openedx_tagging/core/tagging/migrations/0006_auto_20230802_1631.py b/openedx_tagging/core/tagging/migrations/0006_auto_20230802_1631.py
index 87bab9cd7..1bb738545 100644
--- a/openedx_tagging/core/tagging/migrations/0006_auto_20230802_1631.py
+++ b/openedx_tagging/core/tagging/migrations/0006_auto_20230802_1631.py
@@ -1,7 +1,8 @@
# Generated by Django 3.2.19 on 2023-08-02 21:31
-from django.db import migrations, models
import django.db.models.deletion
+from django.db import migrations, models
+
import openedx_tagging.core.tagging.models.import_export
diff --git a/openedx_tagging/core/tagging/migrations/0007_tag_import_task_log_null_fix.py b/openedx_tagging/core/tagging/migrations/0007_tag_import_task_log_null_fix.py
new file mode 100644
index 000000000..c4a4067a2
--- /dev/null
+++ b/openedx_tagging/core/tagging/migrations/0007_tag_import_task_log_null_fix.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.20 on 2023-08-17 21:08
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('oel_tagging', '0006_auto_20230802_1631'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='tagimporttask',
+ name='log',
+ field=models.TextField(blank=True, default=None, help_text='Action execution logs'),
+ ),
+ ]
diff --git a/openedx_tagging/core/tagging/migrations/0008_taxonomy_description_not_null.py b/openedx_tagging/core/tagging/migrations/0008_taxonomy_description_not_null.py
new file mode 100644
index 000000000..37b352823
--- /dev/null
+++ b/openedx_tagging/core/tagging/migrations/0008_taxonomy_description_not_null.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.2.20 on 2023-08-23 18:14
+
+from django.db import migrations
+
+import openedx_learning.lib.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('oel_tagging', '0007_tag_import_task_log_null_fix'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='taxonomy',
+ name='description',
+ field=openedx_learning.lib.fields.MultiCollationTextField(blank=True, default='', help_text='Provides extra information for the user when applying tags from this taxonomy to an object.'),
+ preserve_default=False,
+ ),
+ ]
diff --git a/openedx_tagging/core/tagging/models/__init__.py b/openedx_tagging/core/tagging/models/__init__.py
index 295e38bdb..226d9991b 100644
--- a/openedx_tagging/core/tagging/models/__init__.py
+++ b/openedx_tagging/core/tagging/models/__init__.py
@@ -1,15 +1,3 @@
-from .base import (
- Tag,
- Taxonomy,
- ObjectTag,
-)
-from .system_defined import (
- ModelObjectTag,
- ModelSystemDefinedTaxonomy,
- UserSystemDefinedTaxonomy,
- LanguageTaxonomy,
-)
-from .import_export import (
- TagImportTask,
- TagImportTaskState,
-)
+from .base import ObjectTag, Tag, Taxonomy
+from .import_export import TagImportTask, TagImportTaskState
+from .system_defined import LanguageTaxonomy, ModelObjectTag, ModelSystemDefinedTaxonomy, UserSystemDefinedTaxonomy
diff --git a/openedx_tagging/core/tagging/models/base.py b/openedx_tagging/core/tagging/models/base.py
index efdbda55a..a8e0ea718 100644
--- a/openedx_tagging/core/tagging/models/base.py
+++ b/openedx_tagging/core/tagging/models/base.py
@@ -1,15 +1,17 @@
-""" Tagging app base data models """
+"""
+Tagging app base data models
+"""
+from __future__ import annotations
+
import logging
-from typing import List, Type, Union
+from typing import List
from django.db import models
from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
+from typing_extensions import Self # Until we upgrade to python 3.11
-from openedx_learning.lib.fields import (
- MultiCollationTextField,
- case_insensitive_char_field,
-)
+from openedx_learning.lib.fields import MultiCollationTextField, case_insensitive_char_field
log = logging.getLogger(__name__)
@@ -95,7 +97,7 @@ def get_lineage(self) -> Lineage:
Performance note: may perform as many as TAXONOMY_MAX_DEPTH select queries.
"""
lineage: Lineage = []
- tag = self
+ tag: Tag | None = self
depth = TAXONOMY_MAX_DEPTH
while tag and depth > 0:
lineage.insert(0, tag.value)
@@ -119,7 +121,6 @@ class Taxonomy(models.Model):
),
)
description = MultiCollationTextField(
- null=True,
blank=True,
help_text=_(
"Provides extra information for the user when applying tags from this taxonomy to an object."
@@ -187,7 +188,7 @@ def __str__(self):
return f"<{self.__class__.__name__}> ({self.id}) {self.name}"
@property
- def object_tag_class(self) -> Type:
+ def object_tag_class(self) -> type[ObjectTag]:
"""
Returns the ObjectTag subclass associated with this taxonomy, which is ObjectTag by default.
@@ -196,7 +197,7 @@ def object_tag_class(self) -> Type:
return ObjectTag
@property
- def taxonomy_class(self) -> Type:
+ def taxonomy_class(self) -> type[Taxonomy] | None:
"""
Returns the Taxonomy subclass associated with this instance, or None if none supplied.
@@ -206,16 +207,8 @@ def taxonomy_class(self) -> Type:
return import_string(self._taxonomy_class)
return None
- @property
- def system_defined(self) -> bool:
- """
- Indicates that tags and metadata for this taxonomy are maintained by the system;
- taxonomy admins will not be permitted to modify them.
- """
- return False
-
@taxonomy_class.setter
- def taxonomy_class(self, taxonomy_class: Union[Type, None]):
+ def taxonomy_class(self, taxonomy_class: type[Taxonomy] | None):
"""
Assigns the given taxonomy_class's module path.class to the field.
@@ -234,6 +227,14 @@ def taxonomy_class(self, taxonomy_class: Union[Type, None]):
else:
self._taxonomy_class = None
+ @property
+ def system_defined(self) -> bool:
+ """
+ Indicates that tags and metadata for this taxonomy are maintained by the system;
+ taxonomy admins will not be permitted to modify them.
+ """
+ return False
+
def cast(self):
"""
Returns the current Taxonomy instance cast into its taxonomy_class.
@@ -252,7 +253,7 @@ def cast(self):
return self
- def copy(self, taxonomy: "Taxonomy") -> "Taxonomy":
+ def copy(self, taxonomy: Taxonomy) -> Taxonomy:
"""
Copy the fields from the given Taxonomy into the current instance.
"""
@@ -267,22 +268,25 @@ def copy(self, taxonomy: "Taxonomy") -> "Taxonomy":
self._taxonomy_class = taxonomy._taxonomy_class
return self
- def get_tags(self, tag_set: models.QuerySet = None) -> List[Tag]:
+ def get_tags(self, tag_set: models.QuerySet | None = None) -> list[Tag]:
"""
- Returns a list of all Tags in the current taxonomy, from the root(s) down to TAXONOMY_MAX_DEPTH tags, in tree order.
+ Returns a list of all Tags in the current taxonomy, from the root(s)
+ down to TAXONOMY_MAX_DEPTH tags, in tree order.
Use `tag_set` to do an initial filtering of the tags.
- Annotates each returned Tag with its ``depth`` in the tree (starting at 0).
+ Annotates each returned Tag with its ``depth`` in the tree (starting at
+ 0).
- Performance note: may perform as many as TAXONOMY_MAX_DEPTH select queries.
+ Performance note: may perform as many as TAXONOMY_MAX_DEPTH select
+ queries.
"""
- tags = []
+ tags: list[Tag] = []
if self.allow_free_text:
return tags
if tag_set is None:
- tag_set = self.tag_set
+ tag_set = self.tag_set.all()
parents = None
for depth in range(TAXONOMY_MAX_DEPTH):
@@ -336,7 +340,7 @@ def validate_object_tag(
def _check_taxonomy(
self,
- object_tag: "ObjectTag",
+ object_tag: ObjectTag,
) -> bool:
"""
Returns True if the given object tag is valid for the current Taxonomy.
@@ -344,11 +348,11 @@ def _check_taxonomy(
Subclasses can override this method to perform their own taxonomy validation checks.
"""
# Must be linked to this taxonomy
- return object_tag.taxonomy_id and object_tag.taxonomy_id == self.id
+ return (object_tag.taxonomy_id is not None) and object_tag.taxonomy_id == self.id
def _check_tag(
self,
- object_tag: "ObjectTag",
+ object_tag: ObjectTag,
) -> bool:
"""
Returns True if the given object tag's value is valid for the current Taxonomy.
@@ -360,11 +364,11 @@ def _check_tag(
return bool(object_tag.value)
# Closed taxonomies need an associated tag in this taxonomy
- return object_tag.tag_id and object_tag.tag.taxonomy_id == self.id
+ return (object_tag.tag is not None) and object_tag.tag.taxonomy_id == self.id
def _check_object(
self,
- object_tag: "ObjectTag",
+ object_tag: ObjectTag,
) -> bool:
"""
Returns True if the given object tag's object is valid for the current Taxonomy.
@@ -375,9 +379,9 @@ def _check_object(
def tag_object(
self,
- tags: List,
+ tags: list[str],
object_id: str,
- ) -> List["ObjectTag"]:
+ ) -> list[ObjectTag]:
"""
Replaces the existing ObjectTag entries for the current taxonomy + object_id with the given list of tags.
If self.allows_free_text, then the list should be a list of tag values.
@@ -433,7 +437,7 @@ def tag_object(
def autocomplete_tags(
self,
search: str,
- object_id: str = None,
+ object_id: str | None = None,
) -> models.QuerySet:
"""
Provides auto-complete suggestions by matching the `search` string against existing
@@ -456,11 +460,11 @@ def autocomplete_tags(
search the suggestions on a list of available tags.
"""
# Fetch tags that the object already has to exclude them from the result
- excluded_tags = []
+ excluded_tags: list[str] = []
if object_id:
- excluded_tags = self.objecttag_set.filter(object_id=object_id).values_list(
+ excluded_tags = list(self.objecttag_set.filter(object_id=object_id).values_list(
"_value", flat=True
- )
+ ))
return (
# Fetch object tags from this taxonomy whose value contains the search
self.objecttag_set.filter(_value__icontains=search)
@@ -508,7 +512,8 @@ class ObjectTag(models.Model):
default=None,
on_delete=models.SET_NULL,
help_text=_(
- "Taxonomy that this object tag belongs to. Used for validating the tag and provides the tag's 'name' if set."
+ "Taxonomy that this object tag belongs to. "
+ "Used for validating the tag and provides the tag's 'name' if set."
),
)
tag = models.ForeignKey(
@@ -565,7 +570,7 @@ def name(self) -> str:
If taxonomy is set, then returns its name.
Otherwise, returns the cached _name field.
"""
- return self.taxonomy.name if self.taxonomy_id else self._name
+ return self.taxonomy.name if self.taxonomy else self._name
@name.setter
def name(self, name: str):
@@ -582,7 +587,7 @@ def value(self) -> str:
If tag is set, then returns its value.
Otherwise, returns the cached _value field.
"""
- return self.tag.value if self.tag_id else self._value
+ return self.tag.value if self.tag else self._value
@value.setter
def value(self, value: str):
@@ -599,7 +604,7 @@ def tag_ref(self) -> str:
If tag is set, then returns its id.
Otherwise, returns the cached _value field.
"""
- return self.tag.id if self.tag_id else self._value
+ return self.tag.id if self.tag else self._value
@tag_ref.setter
def tag_ref(self, tag_ref: str):
@@ -610,7 +615,7 @@ def tag_ref(self, tag_ref: str):
"""
self.value = tag_ref
- if self.taxonomy_id:
+ if self.taxonomy:
try:
self.tag = self.taxonomy.tag_set.get(pk=tag_ref)
self.value = self.tag.value
@@ -625,7 +630,7 @@ def is_valid(self) -> bool:
A valid ObjectTag must be linked to a Taxonomy, and be a valid tag in that taxonomy.
"""
- return self.taxonomy_id and self.taxonomy.validate_object_tag(self)
+ return self.taxonomy.validate_object_tag(self) if self.taxonomy else False
def get_lineage(self) -> Lineage:
"""
@@ -634,7 +639,7 @@ def get_lineage(self) -> Lineage:
If linked to a Tag, returns its lineage.
Otherwise, returns an array containing its value string.
"""
- return self.tag.get_lineage() if self.tag_id else [self._value]
+ return self.tag.get_lineage() if self.tag else [self._value]
def resync(self) -> bool:
"""
@@ -652,7 +657,7 @@ def resync(self) -> bool:
# Locate an enabled taxonomy matching _name, and maybe a tag matching _value
if not self.taxonomy_id:
# Use the linked tag's taxonomy if there is one.
- if self.tag_id:
+ if self.tag:
self.taxonomy_id = self.tag.taxonomy_id
changed = True
else:
@@ -679,7 +684,7 @@ def resync(self) -> bool:
self.tag = None
# Sync the stored _name with the taxonomy.name
- if self.taxonomy_id and self._name != self.taxonomy.name:
+ if self.taxonomy and self._name != self.taxonomy.name:
self.name = self.taxonomy.name
changed = True
@@ -698,13 +703,13 @@ def resync(self) -> bool:
return changed
@classmethod
- def cast(cls, object_tag: "ObjectTag") -> "ObjectTag":
+ def cast(cls, object_tag: ObjectTag) -> Self:
"""
Returns a cls instance with the same properties as the given ObjectTag.
"""
return cls().copy(object_tag)
- def copy(self, object_tag: "ObjectTag") -> "ObjectTag":
+ def copy(self, object_tag: ObjectTag) -> Self:
"""
Copy the fields from the given ObjectTag into the current instance.
"""
diff --git a/openedx_tagging/core/tagging/models/import_export.py b/openedx_tagging/core/tagging/models/import_export.py
index 1deaf9c9d..c1b41b365 100644
--- a/openedx_tagging/core/tagging/models/import_export.py
+++ b/openedx_tagging/core/tagging/models/import_export.py
@@ -1,8 +1,9 @@
from datetime import datetime
from enum import Enum
-from django.db import models
-from django.utils.translation import gettext_lazy as _
+from django.db import models
+from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy
from .base import Taxonomy
@@ -29,13 +30,13 @@ class TagImportTask(models.Model):
)
log = models.TextField(
- null=True, default=None, help_text=_("Action execution logs")
+ blank=True, default=None, help_text=gettext_lazy("Action execution logs")
)
status = models.CharField(
max_length=20,
choices=[(status, status.value) for status in TagImportTaskState],
- help_text=_("Task status"),
+ help_text=gettext_lazy("Task status"),
)
creation_date = models.DateTimeField(auto_now_add=True)
diff --git a/openedx_tagging/core/tagging/models/system_defined.py b/openedx_tagging/core/tagging/models/system_defined.py
index 7b39b9c2a..275d6e97d 100644
--- a/openedx_tagging/core/tagging/models/system_defined.py
+++ b/openedx_tagging/core/tagging/models/system_defined.py
@@ -1,6 +1,10 @@
-""" Tagging app system-defined taxonomies data models """
+"""
+Tagging app system-defined taxonomies data models
+"""
+from __future__ import annotations
+
import logging
-from typing import Any, List, Type, Union
+from typing import Any
from django.conf import settings
from django.contrib.auth import get_user_model
@@ -8,7 +12,7 @@
from openedx_tagging.core.tagging.models.base import ObjectTag
-from .base import Tag, Taxonomy, ObjectTag
+from .base import ObjectTag, Tag, Taxonomy
log = logging.getLogger(__name__)
@@ -48,7 +52,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
@property
- def tag_class_model(self) -> Type:
+ def tag_class_model(self) -> type[models.Model]:
"""
Subclasses must implement this method to return the Django.model
class referenced by these object tags.
@@ -64,7 +68,7 @@ def tag_class_value(self) -> str:
"""
return "pk"
- def get_instance(self) -> Union[models.Model, None]:
+ def get_instance(self) -> models.Model | None:
"""
Returns the instance of tag_class_model associated with this object tag, or None if not found.
"""
@@ -104,7 +108,7 @@ def _resync_tag(self) -> bool:
@property
def tag_ref(self) -> str:
- return (self.tag.external_id or self.tag.id) if self.tag_id else self._value
+ return (self.tag.external_id or self.tag.id) if self.tag else self._value
@tag_ref.setter
def tag_ref(self, tag_ref: str):
@@ -115,7 +119,7 @@ def tag_ref(self, tag_ref: str):
"""
self.value = tag_ref
- if self.taxonomy_id:
+ if self.taxonomy:
try:
self.tag = self.taxonomy.tag_set.get(
external_id=tag_ref,
@@ -159,9 +163,9 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
@property
- def object_tag_class(self) -> Type:
+ def object_tag_class(self) -> type[ModelObjectTag]:
"""
- Returns the ObjectTag subclass associated with this taxonomy.
+ Returns the ModelObjectTag subclass associated with this taxonomy.
Model Taxonomy subclasses must implement this to provide a ModelObjectTag subclass.
"""
@@ -192,7 +196,7 @@ class Meta:
proxy = True
@property
- def tag_class_model(self) -> Type:
+ def tag_class_model(self) -> type[models.Model]:
"""
Associate the user model
"""
@@ -217,7 +221,7 @@ class Meta:
proxy = True
@property
- def object_tag_class(self) -> Type:
+ def object_tag_class(self):
"""
Returns the ObjectTag subclass associated with this taxonomy, which is ModelObjectTag by default.
@@ -237,7 +241,7 @@ class LanguageTaxonomy(SystemDefinedTaxonomy):
class Meta:
proxy = True
- def get_tags(self, tag_set: models.QuerySet = None) -> List[Tag]:
+ def get_tags(self, tag_set: models.QuerySet | None = None) -> list[Tag]:
"""
Returns a list of all the available Language Tags, annotated with ``depth`` = 0.
"""
@@ -245,7 +249,7 @@ def get_tags(self, tag_set: models.QuerySet = None) -> List[Tag]:
tag_set = self.tag_set.filter(external_id__in=available_langs)
return super().get_tags(tag_set=tag_set)
- def _get_available_languages(cls) -> List[str]:
+ def _get_available_languages(cls) -> set[str]:
"""
Get available languages from Django LANGUAGE.
"""
@@ -260,6 +264,8 @@ def _check_valid_language(self, object_tag: ObjectTag) -> bool:
Returns True if the tag is on the available languages
"""
available_langs = self._get_available_languages()
+ if not object_tag.tag:
+ raise AttributeError("Expected object_tag.tag to be set")
return object_tag.tag.external_id in available_langs
def _check_tag(self, object_tag: ObjectTag) -> bool:
diff --git a/openedx_tagging/core/tagging/rest_api/urls.py b/openedx_tagging/core/tagging/rest_api/urls.py
index d7f012bb7..2cd82d03b 100644
--- a/openedx_tagging/core/tagging/rest_api/urls.py
+++ b/openedx_tagging/core/tagging/rest_api/urls.py
@@ -2,7 +2,7 @@
Taxonomies API URLs.
"""
-from django.urls import path, include
+from django.urls import include, path
from .v1 import urls as v1_urls
diff --git a/openedx_tagging/core/tagging/rest_api/v1/serializers.py b/openedx_tagging/core/tagging/rest_api/v1/serializers.py
index 593b72989..e278b84fe 100644
--- a/openedx_tagging/core/tagging/rest_api/v1/serializers.py
+++ b/openedx_tagging/core/tagging/rest_api/v1/serializers.py
@@ -4,7 +4,7 @@
from rest_framework import serializers
-from openedx_tagging.core.tagging.models import Taxonomy, ObjectTag
+from openedx_tagging.core.tagging.models import ObjectTag, Taxonomy
class TaxonomyListQueryParamsSerializer(serializers.Serializer):
diff --git a/openedx_tagging/core/tagging/rest_api/v1/urls.py b/openedx_tagging/core/tagging/rest_api/v1/urls.py
index c449eb5c2..02cb48e40 100644
--- a/openedx_tagging/core/tagging/rest_api/v1/urls.py
+++ b/openedx_tagging/core/tagging/rest_api/v1/urls.py
@@ -2,10 +2,9 @@
Taxonomies API v1 URLs.
"""
+from django.urls.conf import include, path
from rest_framework.routers import DefaultRouter
-from django.urls.conf import path, include
-
from . import views
router = DefaultRouter()
diff --git a/openedx_tagging/core/tagging/rest_api/v1/views.py b/openedx_tagging/core/tagging/rest_api/v1/views.py
index 13e00acd6..3e8ae18c2 100644
--- a/openedx_tagging/core/tagging/rest_api/v1/views.py
+++ b/openedx_tagging/core/tagging/rest_api/v1/views.py
@@ -1,23 +1,21 @@
"""
Tagging API Views
"""
+from django.db import models
from django.http import Http404
+from django.shortcuts import get_object_or_404
from rest_framework import status
-from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from rest_framework.response import Response
+from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
-from ...api import (
- create_taxonomy,
- get_taxonomy,
- get_taxonomies,
- get_object_tags,
-)
-from .permissions import TaxonomyObjectPermissions, ObjectTagObjectPermissions
+from ...api import create_taxonomy, get_object_tags, get_taxonomies, get_taxonomy
+from ...models import Taxonomy
+from .permissions import ObjectTagObjectPermissions, TaxonomyObjectPermissions
from .serializers import (
- TaxonomyListQueryParamsSerializer,
- TaxonomySerializer,
ObjectTagListQueryParamsSerializer,
ObjectTagSerializer,
+ TaxonomyListQueryParamsSerializer,
+ TaxonomySerializer,
)
@@ -26,14 +24,15 @@ class TaxonomyView(ModelViewSet):
View to list, create, retrieve, update, or delete Taxonomies.
**List Query Parameters**
- * enabled (optional) - Filter by enabled status. Valid values: true, false, 1, 0, "true", "false", "1"
+ * enabled (optional) - Filter by enabled status. Valid values: true,
+ false, 1, 0, "true", "false", "1"
* page (optional) - Page number (default: 1)
* page_size (optional) - Number of items per page (default: 10)
**List Example Requests**
- GET api/tagging/v1/taxonomy - Get all taxonomies
- GET api/tagging/v1/taxonomy?enabled=true - Get all enabled taxonomies
- GET api/tagging/v1/taxonomy?enabled=false - Get all disabled taxonomies
+ GET api/tagging/v1/taxonomy - Get all taxonomies
+ GET api/tagging/v1/taxonomy?enabled=true - Get all enabled taxonomies
+ GET api/tagging/v1/taxonomy?enabled=false - Get all disabled taxonomies
**List Query Returns**
* 200 - Success
@@ -44,24 +43,32 @@ class TaxonomyView(ModelViewSet):
* pk (required): - The pk of the taxonomy to retrieve
**Retrieve Example Requests**
- GET api/tagging/v1/taxonomy/:pk - Get a specific taxonomy
+ GET api/tagging/v1/taxonomy/:pk - Get a specific taxonomy
**Retrieve Query Returns**
* 200 - Success
- * 404 - Taxonomy not found or User does not have permission to access the taxonomy
+ * 404 - Taxonomy not found or User does not have permission to access
+ the taxonomy
**Create Parameters**
- * name (required): User-facing label used when applying tags from this taxonomy to Open edX objects.
- * description (optional): Provides extra information for the user when applying tags from this taxonomy to an object.
- * enabled (optional): Only enabled taxonomies will be shown to authors (default: true).
- * required (optional): Indicates that one or more tags from this taxonomy must be added to an object (default: False).
- * allow_multiple (optional): Indicates that multiple tags from this taxonomy may be added to an object (default: False).
- * allow_free_text (optional): Indicates that tags in this taxonomy need not be predefined; authors may enter their own tag values (default: False).
+ * name (required): User-facing label used when applying tags from this
+ taxonomy to Open edX objects.
+ * description (optional): Provides extra information for the user when
+ applying tags from this taxonomy to an object.
+ * enabled (optional): Only enabled taxonomies will be shown to authors
+ (default: true).
+ * required (optional): Indicates that one or more tags from this
+ taxonomy must be added to an object (default: False).
+ * allow_multiple (optional): Indicates that multiple tags from this
+ taxonomy may be added to an object (default: False).
+ * allow_free_text (optional): Indicates that tags in this taxonomy need
+ not be predefined; authors may enter their own tag values (default:
+ False).
**Create Example Requests**
- POST api/tagging/v1/taxonomy - Create a taxonomy
+ POST api/tagging/v1/taxonomy - Create a taxonomy
{
- "name": "Taxonomy Name", - User-facing label used when applying tags from this taxonomy to Open edX objects."
+ "name": "Taxonomy Name",
"description": "This is a description",
"enabled": True,
"required": True,
@@ -77,15 +84,20 @@ class TaxonomyView(ModelViewSet):
* pk (required): - The pk of the taxonomy to update
**Update Request Body**
- * name (optional): User-facing label used when applying tags from this taxonomy to Open edX objects.
- * description (optional): Provides extra information for the user when applying tags from this taxonomy to an object.
+ * name (optional): User-facing label used when applying tags from this
+ taxonomy to Open edX objects.
+ * description (optional): Provides extra information for the user when
+ applying tags from this taxonomy to an object.
* enabled (optional): Only enabled taxonomies will be shown to authors.
- * required (optional): Indicates that one or more tags from this taxonomy must be added to an object.
- * allow_multiple (optional): Indicates that multiple tags from this taxonomy may be added to an object.
- * allow_free_text (optional): Indicates that tags in this taxonomy need not be predefined; authors may enter their own tag values.
+ * required (optional): Indicates that one or more tags from this
+ taxonomy must be added to an object.
+ * allow_multiple (optional): Indicates that multiple tags from this
+ taxonomy may be added to an object.
+ * allow_free_text (optional): Indicates that tags in this taxonomy need
+ not be predefined; authors may enter their own tag values.
**Update Example Requests**
- PUT api/tagging/v1/taxonomy/:pk - Update a taxonomy
+ PUT api/tagging/v1/taxonomy/:pk - Update a taxonomy
{
"name": "Taxonomy New Name",
"description": "This is a new description",
@@ -94,7 +106,7 @@ class TaxonomyView(ModelViewSet):
"allow_multiple": False,
"allow_free_text": True,
}
- PATCH api/tagging/v1/taxonomy/:pk - Partially update a taxonomy
+ PATCH api/tagging/v1/taxonomy/:pk - Partially update a taxonomy
{
"name": "Taxonomy New Name",
}
@@ -107,7 +119,7 @@ class TaxonomyView(ModelViewSet):
* pk (required): - The pk of the taxonomy to delete
**Delete Example Requests**
- DELETE api/tagging/v1/taxonomy/:pk - Delete a taxonomy
+ DELETE api/tagging/v1/taxonomy/:pk - Delete a taxonomy
**Delete Query Returns**
* 200 - Success
@@ -119,12 +131,12 @@ class TaxonomyView(ModelViewSet):
serializer_class = TaxonomySerializer
permission_classes = [TaxonomyObjectPermissions]
- def get_object(self):
+ def get_object(self) -> Taxonomy:
"""
Return the requested taxonomy object, if the user has appropriate
permissions.
"""
- pk = self.kwargs.get("pk")
+ pk = int(self.kwargs["pk"])
taxonomy = get_taxonomy(pk)
if not taxonomy:
raise Http404("Taxonomy not found")
@@ -132,7 +144,7 @@ def get_object(self):
return taxonomy
- def get_queryset(self):
+ def get_queryset(self) -> models.QuerySet:
"""
Return a list of taxonomies.
@@ -148,7 +160,7 @@ def get_queryset(self):
return get_taxonomies(enabled)
- def perform_create(self, serializer):
+ def perform_create(self, serializer) -> None:
"""
Create a new taxonomy.
"""
@@ -196,13 +208,13 @@ class ObjectTagView(ReadOnlyModelViewSet):
permission_classes = [ObjectTagObjectPermissions]
lookup_field = "object_id"
- def get_queryset(self):
+ def get_queryset(self) -> models.QuerySet:
"""
Return a queryset of object tags for a given object.
If a taxonomy is passed in, object tags are limited to that taxonomy.
"""
- object_id = self.kwargs.get("object_id")
+ object_id: str = self.kwargs["object_id"]
query_params = ObjectTagListQueryParamsSerializer(
data=self.request.query_params.dict()
)
diff --git a/openedx_tagging/core/tagging/rules.py b/openedx_tagging/core/tagging/rules.py
index b79a56344..8f0852a10 100644
--- a/openedx_tagging/core/tagging/rules.py
+++ b/openedx_tagging/core/tagging/rules.py
@@ -1,20 +1,26 @@
-"""Django rules-based permissions for tagging"""
+"""
+Django rules-based permissions for tagging
+"""
+from __future__ import annotations
-import rules
-from django.contrib.auth import get_user_model
+from typing import Callable, Union
+
+import django.contrib.auth.models
+# typing support in rules depends on https://github.com/dfunckt/django-rules/pull/177
+import rules # type: ignore[import]
from .models import ObjectTag, Tag, Taxonomy
-User = get_user_model()
+UserType = Union[django.contrib.auth.models.User, django.contrib.auth.models.AnonymousUser]
# Global staff are taxonomy admins.
# (Superusers can already do anything)
-is_taxonomy_admin = rules.is_staff
+is_taxonomy_admin: Callable[[UserType], bool] = rules.is_staff
@rules.predicate
-def can_view_taxonomy(user: User, taxonomy: Taxonomy = None) -> bool:
+def can_view_taxonomy(user: UserType, taxonomy: Taxonomy | None = None) -> bool:
"""
Anyone can view an enabled taxonomy or list all taxonomies,
but only taxonomy admins can view a disabled taxonomy.
@@ -23,22 +29,22 @@ def can_view_taxonomy(user: User, taxonomy: Taxonomy = None) -> bool:
@rules.predicate
-def can_change_taxonomy(user: User, taxonomy: Taxonomy = None) -> bool:
+def can_change_taxonomy(user: UserType, taxonomy: Taxonomy | None = None) -> bool:
"""
Even taxonomy admins cannot change system taxonomies.
"""
return is_taxonomy_admin(user) and (
- not taxonomy or (taxonomy and not taxonomy.cast().system_defined)
+ not taxonomy or bool(taxonomy and not taxonomy.cast().system_defined)
)
@rules.predicate
-def can_change_tag(user: User, tag: Tag = None) -> bool:
+def can_change_tag(user: UserType, tag: Tag | None = None) -> bool:
"""
Even taxonomy admins cannot add tags to system taxonomies (their tags are system-defined), or free-text taxonomies
(these don't have predefined tags).
"""
- taxonomy = tag.taxonomy.cast() if (tag and tag.taxonomy_id) else None
+ taxonomy = tag.taxonomy.cast() if (tag and tag.taxonomy) else None
return is_taxonomy_admin(user) and (
not tag
or not taxonomy
@@ -47,12 +53,12 @@ def can_change_tag(user: User, tag: Tag = None) -> bool:
@rules.predicate
-def can_change_object_tag(user: User, object_tag: ObjectTag = None) -> bool:
+def can_change_object_tag(user: UserType, object_tag: ObjectTag | None = None) -> bool:
"""
Taxonomy admins can create or modify object tags on enabled taxonomies.
"""
taxonomy = (
- object_tag.taxonomy.cast() if (object_tag and object_tag.taxonomy_id) else None
+ object_tag.taxonomy.cast() if (object_tag and object_tag.taxonomy) else None
)
object_tag = taxonomy.object_tag_class.cast(object_tag) if taxonomy else object_tag
return is_taxonomy_admin(user) and (
diff --git a/openedx_tagging/core/tagging/urls.py b/openedx_tagging/core/tagging/urls.py
index da2c52081..effb166a7 100644
--- a/openedx_tagging/core/tagging/urls.py
+++ b/openedx_tagging/core/tagging/urls.py
@@ -2,7 +2,7 @@
Tagging API URLs.
"""
-from django.urls import path, include
+from django.urls import include, path
from .rest_api import urls
diff --git a/openedx_tagging/py.typed b/openedx_tagging/py.typed
new file mode 100644
index 000000000..e69de29bb
diff --git a/projects/dev.py b/projects/dev.py
index 9735b35de..2dacb56bd 100644
--- a/projects/dev.py
+++ b/projects/dev.py
@@ -1,6 +1,7 @@
"""
-
+Django settings for testing and development purposes
"""
+from __future__ import annotations
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / {dir_name} /
@@ -96,7 +97,7 @@
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
]
-STATICFILES_DIRS = [
+STATICFILES_DIRS: list[Path] = [
# BASE_DIR / 'projects' / 'static'
]
MEDIA_URL = "/media/"
diff --git a/requirements/dev.txt b/requirements/dev.txt
index d5ad00f9d..0fe66827f 100644
--- a/requirements/dev.txt
+++ b/requirements/dev.txt
@@ -110,6 +110,8 @@ django==3.2.19
# -r requirements/quality.txt
# django-crum
# django-debug-toolbar
+ # django-stubs
+ # django-stubs-ext
# django-waffle
# djangorestframework
# drf-jwt
@@ -124,6 +126,14 @@ django-debug-toolbar==4.1.0
# via
# -r requirements/dev.in
# -r requirements/quality.txt
+django-stubs==4.2.3
+ # via
+ # -r requirements/quality.txt
+ # djangorestframework-stubs
+django-stubs-ext==4.2.2
+ # via
+ # -r requirements/quality.txt
+ # django-stubs
django-waffle==4.0.0
# via
# -r requirements/quality.txt
@@ -134,6 +144,8 @@ djangorestframework==3.14.0
# -r requirements/quality.txt
# drf-jwt
# edx-drf-extensions
+djangorestframework-stubs==3.14.2
+ # via -r requirements/quality.txt
docutils==0.20.1
# via
# -r requirements/quality.txt
@@ -239,6 +251,15 @@ more-itertools==9.1.0
# via
# -r requirements/quality.txt
# jaraco-classes
+mypy==1.5.1
+ # via
+ # -r requirements/quality.txt
+ # django-stubs
+ # djangorestframework-stubs
+mypy-extensions==1.0.0
+ # via
+ # -r requirements/quality.txt
+ # mypy
mysqlclient==2.1.1
# via -r requirements/quality.txt
newrelic==8.9.0
@@ -378,6 +399,7 @@ readme-renderer==40.0
requests==2.31.0
# via
# -r requirements/quality.txt
+ # djangorestframework-stubs
# edx-drf-extensions
# requests-toolbelt
# twine
@@ -438,7 +460,9 @@ tomli==2.0.1
# -r requirements/quality.txt
# build
# coverage
+ # django-stubs
# import-linter
+ # mypy
# pylint
# pyproject-hooks
# pytest
@@ -456,14 +480,36 @@ tox-battery==0.6.1
# via -r requirements/dev.in
twine==4.0.2
# via -r requirements/quality.txt
+types-pytz==2023.3.0.1
+ # via
+ # -r requirements/quality.txt
+ # django-stubs
+types-pyyaml==6.0.12.11
+ # via
+ # -r requirements/quality.txt
+ # django-stubs
+ # djangorestframework-stubs
+types-requests==2.31.0.2
+ # via
+ # -r requirements/quality.txt
+ # djangorestframework-stubs
+types-urllib3==1.26.25.14
+ # via
+ # -r requirements/quality.txt
+ # types-requests
typing-extensions==4.6.3
# via
# -r requirements/ci.txt
# -r requirements/quality.txt
# asgiref
# astroid
+ # django-stubs
+ # django-stubs-ext
+ # djangorestframework-stubs
# grimp
# import-linter
+ # mypy
+ # pylint
tzdata==2023.3
# via
# -r requirements/quality.txt
diff --git a/requirements/doc.txt b/requirements/doc.txt
index 06ae86ff7..35ac24a0a 100644
--- a/requirements/doc.txt
+++ b/requirements/doc.txt
@@ -1,5 +1,5 @@
#
-# This file is autogenerated by pip-compile with Python 3.10
+# This file is autogenerated by pip-compile with Python 3.8
# by the following command:
#
# make upgrade
@@ -97,6 +97,14 @@ django-crum==0.7.9
# edx-django-utils
django-debug-toolbar==4.1.0
# via -r requirements/test.txt
+django-stubs==4.2.3
+ # via
+ # -r requirements/test.txt
+ # djangorestframework-stubs
+django-stubs-ext==4.2.2
+ # via
+ # -r requirements/test.txt
+ # django-stubs
django-waffle==4.0.0
# via
# -r requirements/test.txt
@@ -107,6 +115,8 @@ djangorestframework==3.14.0
# -r requirements/test.txt
# drf-jwt
# edx-drf-extensions
+djangorestframework-stubs==3.14.2
+ # via -r requirements/test.txt
doc8==1.1.1
# via -r requirements/doc.in
docutils==0.19
@@ -165,6 +175,15 @@ markupsafe==2.1.3
# jinja2
mock==5.0.2
# via -r requirements/test.txt
+mypy==1.5.1
+ # via
+ # -r requirements/test.txt
+ # django-stubs
+ # djangorestframework-stubs
+mypy-extensions==1.0.0
+ # via
+ # -r requirements/test.txt
+ # mypy
mysqlclient==2.1.1
# via -r requirements/test.txt
newrelic==8.9.0
@@ -253,6 +272,7 @@ readme-renderer==40.0
requests==2.31.0
# via
# -r requirements/test.txt
+ # djangorestframework-stubs
# edx-drf-extensions
# sphinx
restructuredtext-lint==1.4.0
@@ -315,15 +335,38 @@ tomli==2.0.1
# via
# -r requirements/test.txt
# coverage
+ # django-stubs
# doc8
# import-linter
+ # mypy
# pytest
+types-pytz==2023.3.0.1
+ # via
+ # -r requirements/test.txt
+ # django-stubs
+types-pyyaml==6.0.12.11
+ # via
+ # -r requirements/test.txt
+ # django-stubs
+ # djangorestframework-stubs
+types-requests==2.31.0.2
+ # via
+ # -r requirements/test.txt
+ # djangorestframework-stubs
+types-urllib3==1.26.25.14
+ # via
+ # -r requirements/test.txt
+ # types-requests
typing-extensions==4.6.3
# via
# -r requirements/test.txt
# asgiref
+ # django-stubs
+ # django-stubs-ext
+ # djangorestframework-stubs
# grimp
# import-linter
+ # mypy
# pydata-sphinx-theme
tzdata==2023.3
# via
diff --git a/requirements/quality.txt b/requirements/quality.txt
index 22fb452e9..a6a9044b3 100644
--- a/requirements/quality.txt
+++ b/requirements/quality.txt
@@ -1,5 +1,5 @@
#
-# This file is autogenerated by pip-compile with Python 3.10
+# This file is autogenerated by pip-compile with Python 3.8
# by the following command:
#
# make upgrade
@@ -88,6 +88,8 @@ django==3.2.19
# -r requirements/test.txt
# django-crum
# django-debug-toolbar
+ # django-stubs
+ # django-stubs-ext
# django-waffle
# djangorestframework
# drf-jwt
@@ -99,6 +101,14 @@ django-crum==0.7.9
# edx-django-utils
django-debug-toolbar==4.1.0
# via -r requirements/test.txt
+django-stubs==4.2.3
+ # via
+ # -r requirements/test.txt
+ # djangorestframework-stubs
+django-stubs-ext==4.2.2
+ # via
+ # -r requirements/test.txt
+ # django-stubs
django-waffle==4.0.0
# via
# -r requirements/test.txt
@@ -109,6 +119,8 @@ djangorestframework==3.14.0
# -r requirements/test.txt
# drf-jwt
# edx-drf-extensions
+djangorestframework-stubs==3.14.2
+ # via -r requirements/test.txt
docutils==0.20.1
# via readme-renderer
drf-jwt==1.19.2
@@ -185,6 +197,15 @@ mock==5.0.2
# via -r requirements/test.txt
more-itertools==9.1.0
# via jaraco-classes
+mypy==1.5.1
+ # via
+ # -r requirements/test.txt
+ # django-stubs
+ # djangorestframework-stubs
+mypy-extensions==1.0.0
+ # via
+ # -r requirements/test.txt
+ # mypy
mysqlclient==2.1.1
# via -r requirements/test.txt
newrelic==8.9.0
@@ -286,6 +307,7 @@ readme-renderer==40.0
requests==2.31.0
# via
# -r requirements/test.txt
+ # djangorestframework-stubs
# edx-drf-extensions
# requests-toolbelt
# twine
@@ -331,20 +353,44 @@ tomli==2.0.1
# via
# -r requirements/test.txt
# coverage
+ # django-stubs
# import-linter
+ # mypy
# pylint
# pytest
tomlkit==0.11.8
# via pylint
twine==4.0.2
# via -r requirements/quality.in
+types-pytz==2023.3.0.1
+ # via
+ # -r requirements/test.txt
+ # django-stubs
+types-pyyaml==6.0.12.11
+ # via
+ # -r requirements/test.txt
+ # django-stubs
+ # djangorestframework-stubs
+types-requests==2.31.0.2
+ # via
+ # -r requirements/test.txt
+ # djangorestframework-stubs
+types-urllib3==1.26.25.14
+ # via
+ # -r requirements/test.txt
+ # types-requests
typing-extensions==4.6.3
# via
# -r requirements/test.txt
# asgiref
# astroid
+ # django-stubs
+ # django-stubs-ext
+ # djangorestframework-stubs
# grimp
# import-linter
+ # mypy
+ # pylint
tzdata==2023.3
# via
# -r requirements/test.txt
diff --git a/requirements/test.in b/requirements/test.in
index 50feee663..0d3126aef 100644
--- a/requirements/test.in
+++ b/requirements/test.in
@@ -14,4 +14,7 @@ pytest-django # pytest extension for better Django support
code-annotations # provides commands used by the pii_check make target.
ddt # supports data driven tests
mock # supports overriding classes and methods in tests
+mypy # static type checking
+django-stubs # Typing stubs for Django, so it works with mypy
+djangorestframework-stubs # Typing stubs for DRF
django-debug-toolbar # provides a debug toolbar for Django
diff --git a/requirements/test.txt b/requirements/test.txt
index ba50193f0..51275c8f8 100644
--- a/requirements/test.txt
+++ b/requirements/test.txt
@@ -1,5 +1,5 @@
#
-# This file is autogenerated by pip-compile with Python 3.10
+# This file is autogenerated by pip-compile with Python 3.8
# by the following command:
#
# make upgrade
@@ -72,6 +72,8 @@ ddt==1.6.0
# -r requirements/base.txt
# django-crum
# django-debug-toolbar
+ # django-stubs
+ # django-stubs-ext
# django-waffle
# djangorestframework
# drf-jwt
@@ -83,6 +85,12 @@ django-crum==0.7.9
# edx-django-utils
django-debug-toolbar==4.1.0
# via -r requirements/test.in
+django-stubs==4.2.3
+ # via
+ # -r requirements/test.in
+ # djangorestframework-stubs
+django-stubs-ext==4.2.2
+ # via django-stubs
django-waffle==4.0.0
# via
# -r requirements/base.txt
@@ -93,6 +101,8 @@ djangorestframework==3.14.0
# -r requirements/base.txt
# drf-jwt
# edx-drf-extensions
+djangorestframework-stubs==3.14.2
+ # via -r requirements/test.in
drf-jwt==1.19.2
# via
# -r requirements/base.txt
@@ -129,6 +139,13 @@ markupsafe==2.1.3
# via jinja2
mock==5.0.2
# via -r requirements/test.in
+mypy==1.5.1
+ # via
+ # -r requirements/test.in
+ # django-stubs
+ # djangorestframework-stubs
+mypy-extensions==1.0.0
+ # via mypy
mysqlclient==2.1.1
# via -r requirements/test.in
newrelic==8.9.0
@@ -194,6 +211,7 @@ pyyaml==6.0
requests==2.31.0
# via
# -r requirements/base.txt
+ # djangorestframework-stubs
# edx-drf-extensions
rules==3.3
# via -r requirements/base.txt
@@ -222,14 +240,30 @@ text-unidecode==1.3
tomli==2.0.1
# via
# coverage
+ # django-stubs
# import-linter
+ # mypy
# pytest
+types-pytz==2023.3.0.1
+ # via django-stubs
+types-pyyaml==6.0.12.11
+ # via
+ # django-stubs
+ # djangorestframework-stubs
+types-requests==2.31.0.2
+ # via djangorestframework-stubs
+types-urllib3==1.26.25.14
+ # via types-requests
typing-extensions==4.6.3
# via
# -r requirements/base.txt
# asgiref
+ # django-stubs
+ # django-stubs-ext
+ # djangorestframework-stubs
# grimp
# import-linter
+ # mypy
tzdata==2023.3
# via
# -r requirements/base.txt
diff --git a/tests/__init__.py b/tests/__init__.py
index e69de29bb..afb65fef5 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -0,0 +1,3 @@
+"""
+Tests for Learning Core.
+"""
diff --git a/tests/openedx_learning/core/components/test_models.py b/tests/openedx_learning/core/components/test_models.py
index f41df14d0..54af3de19 100644
--- a/tests/openedx_learning/core/components/test_models.py
+++ b/tests/openedx_learning/core/components/test_models.py
@@ -1,28 +1,30 @@
+"""
+Tests related to the Component models
+"""
from datetime import datetime, timezone
from django.test.testcases import TestCase
-from openedx_learning.core.publishing.api import (
- create_learning_package,
- publish_all_drafts,
-)
from openedx_learning.core.components.api import create_component_and_version
+from openedx_learning.core.publishing.api import LearningPackage, create_learning_package, publish_all_drafts
class TestModelVersioningQueries(TestCase):
"""
Test that Component/ComponentVersion are registered with the publishing app.
"""
+ learning_package: LearningPackage
+ now: datetime
@classmethod
- def setUpTestData(cls):
+ def setUpTestData(cls) -> None: # Note: we must specify '-> None' to opt in to type checking
cls.learning_package = create_learning_package(
"components.TestVersioning",
"Learning Package for Testing Component Versioning",
)
cls.now = datetime(2023, 5, 8, tzinfo=timezone.utc)
- def test_latest_version(self):
+ def test_latest_version(self) -> None:
component, component_version = create_component_and_version(
learning_package_id=self.learning_package.id,
namespace="xblock.v1",
diff --git a/tests/openedx_learning/core/publishing/test_api.py b/tests/openedx_learning/core/publishing/test_api.py
index ebfdf05e1..2a4fdced3 100644
--- a/tests/openedx_learning/core/publishing/test_api.py
+++ b/tests/openedx_learning/core/publishing/test_api.py
@@ -1,16 +1,24 @@
+"""
+Tests of the Publishing app's python API
+"""
from datetime import datetime, timezone
from uuid import UUID
+import pytest
from django.core.exceptions import ValidationError
from django.test import TestCase
-import pytest
from openedx_learning.core.publishing.api import create_learning_package
class CreateLearningPackageTestCase(TestCase):
- def test_normal(self):
- """Normal flow with no errors."""
+ """
+ Test creating a LearningPackage
+ """
+ def test_normal(self) -> None: # Note: we must specify '-> None' to opt in to type checking
+ """
+ Normal flow with no errors.
+ """
key = "my_key"
title = "My Excellent Title with Emoji 🔥"
created = datetime(2023, 4, 2, 15, 9, 0, tzinfo=timezone.utc)
@@ -27,8 +35,10 @@ def test_normal(self):
# Having an actual value here means we were persisted to the database.
assert isinstance(package.id, int)
- def test_auto_datetime(self):
- """Auto-generated created datetime works as expected."""
+ def test_auto_datetime(self) -> None:
+ """
+ Auto-generated created datetime works as expected.
+ """
key = "my_key"
title = "My Excellent Title with Emoji 🔥"
package = create_learning_package(key, title)
@@ -47,8 +57,10 @@ def test_auto_datetime(self):
# Having an actual value here means we were persisted to the database.
assert isinstance(package.id, int)
- def test_non_utc_time(self):
- """Require UTC timezone for created."""
+ def test_non_utc_time(self) -> None:
+ """
+ Require UTC timezone for created.
+ """
with pytest.raises(ValidationError) as excinfo:
create_learning_package("my_key", "A Title", datetime(2023, 4, 2))
message_dict = excinfo.value.message_dict
@@ -57,8 +69,10 @@ def test_non_utc_time(self):
assert "created" in message_dict
assert "updated" in message_dict
- def test_already_exists(self):
- """Raises ValidationError for duplicate keys."""
+ def test_already_exists(self) -> None:
+ """
+ Raises ValidationError for duplicate keys.
+ """
create_learning_package("my_key", "Original")
with pytest.raises(ValidationError) as excinfo:
create_learning_package("my_key", "Duplicate")
diff --git a/tests/openedx_tagging/core/fixtures/tagging.yaml b/tests/openedx_tagging/core/fixtures/tagging.yaml
index 3593a095c..b3c070035 100644
--- a/tests/openedx_tagging/core/fixtures/tagging.yaml
+++ b/tests/openedx_tagging/core/fixtures/tagging.yaml
@@ -205,7 +205,7 @@
pk: 1
fields:
name: Life on Earth
- description: null
+ description: A taxonomy about life on earth.
enabled: true
required: false
allow_multiple: false
@@ -234,7 +234,7 @@
pk: 5
fields:
name: Import Taxonomy Test
- description: null
+ description: ""
enabled: true
required: false
allow_multiple: false
diff --git a/tests/openedx_tagging/core/tagging/import_export/test_actions.py b/tests/openedx_tagging/core/tagging/import_export/test_actions.py
index 1c0b14852..22c85b2a7 100644
--- a/tests/openedx_tagging/core/tagging/import_export/test_actions.py
+++ b/tests/openedx_tagging/core/tagging/import_export/test_actions.py
@@ -1,20 +1,22 @@
"""
Tests for actions
"""
-import ddt
+from __future__ import annotations
+import ddt # type: ignore[import]
from django.test.testcases import TestCase
-from openedx_tagging.core.tagging.models import Tag
-from openedx_tagging.core.tagging.import_export.import_plan import TagItem
from openedx_tagging.core.tagging.import_export.actions import (
- ImportAction,
CreateTag,
- UpdateParentTag,
- RenameTag,
DeleteTag,
+ ImportAction,
+ RenameTag,
+ UpdateParentTag,
WithoutChanges,
)
+from openedx_tagging.core.tagging.import_export.import_plan import TagItem
+from openedx_tagging.core.tagging.models import Tag
+
from .mixins import TestImportExportMixin
@@ -22,7 +24,9 @@ class TestImportActionMixin(TestImportExportMixin):
"""
Mixin for import action tests
"""
- def setUp(self):
+ indexed_actions: dict[str, list[ImportAction]]
+
+ def setUp(self) -> None: # Note: we must specify '-> None' to opt in to type checking
super().setUp()
self.indexed_actions = {
'create': [
@@ -65,7 +69,7 @@ def test_not_implemented_functions(self):
with self.assertRaises(NotImplementedError):
action.execute()
- def test_str(self):
+ def test_str(self) -> None:
expected = "Action import_action (index=100,id=tag_1)"
action = ImportAction(
taxonomy=self.taxonomy,
@@ -103,14 +107,14 @@ def test_search_action(self, action_name, attr, search_value, expected):
('tag_100', False),
)
@ddt.unpack
- def test_validate_parent(self, parent_id, expected):
+ def test_validate_parent(self, parent_id: str, expected: bool):
action = ImportAction(
self.taxonomy,
TagItem(
id='tag_110',
value='_',
parent_id=parent_id,
- index=100
+ index=100,
),
index=100,
)
@@ -152,7 +156,7 @@ def test_validate_parent(self, parent_id, expected):
('Tag 20', None)
)
@ddt.unpack
- def test_validate_value(self, value, expected):
+ def test_validate_value(self, value: str, expected: str | None):
action = ImportAction(
self.taxonomy,
TagItem(
@@ -180,7 +184,7 @@ class TestCreateTag(TestImportActionMixin, TestCase):
('tag_100', True),
)
@ddt.unpack
- def test_applies_for(self, tag_id, expected):
+ def test_applies_for(self, tag_id: str, expected: bool):
result = CreateTag.applies_for(
self.taxonomy,
TagItem(
@@ -196,7 +200,7 @@ def test_applies_for(self, tag_id, expected):
('tag_100', True),
)
@ddt.unpack
- def test_validate_id(self, tag_id, expected):
+ def test_validate_id(self, tag_id: str, expected: bool):
action = CreateTag(
taxonomy=self.taxonomy,
tag=TagItem(
@@ -227,7 +231,7 @@ def test_validate_id(self, tag_id, expected):
('tag_20', "Tag 20", 'tag_1', 0), # Valid
)
@ddt.unpack
- def test_validate(self, tag_id, tag_value, parent_id, expected):
+ def test_validate(self, tag_id: str, tag_value: str, parent_id: str | None, expected: bool):
action = CreateTag(
taxonomy=self.taxonomy,
tag=TagItem(
@@ -242,11 +246,11 @@ def test_validate(self, tag_id, tag_value, parent_id, expected):
self.assertEqual(len(errors), expected)
@ddt.data(
- ('tag_30', 'Tag 30', None), # No parent
- ('tag_31', 'Tag 31', 'tag_3'), # With parent
+ ('tag_30', 'Tag 30', None), # No parent
+ ('tag_31', 'Tag 31', 'tag_3'), # With parent
)
@ddt.unpack
- def test_execute(self, tag_id, value, parent_id):
+ def test_execute(self, tag_id: str, value: str, parent_id: str | None):
tag = TagItem(
id=tag_id,
value=value,
@@ -260,12 +264,12 @@ def test_execute(self, tag_id, value, parent_id):
with self.assertRaises(Tag.DoesNotExist):
self.taxonomy.tag_set.get(external_id=tag_id)
action.execute()
- tag = self.taxonomy.tag_set.get(external_id=tag_id)
- assert tag.value == value
+ tag_obj = self.taxonomy.tag_set.get(external_id=tag_id)
+ assert tag_obj.value == value
if parent_id:
- assert tag.parent.external_id == parent_id
+ assert tag_obj.parent.external_id == parent_id
else:
- assert tag.parent is None
+ assert tag_obj.parent is None
@ddt.ddt
@@ -293,7 +297,7 @@ class TestUpdateParentTag(TestImportActionMixin, TestCase):
),
)
@ddt.unpack
- def test_str(self, tag_id, parent_id, expected):
+ def test_str(self, tag_id: str, parent_id: str, expected: str):
tag_item = TagItem(
id=tag_id,
value='_',
@@ -311,10 +315,10 @@ def test_str(self, tag_id, parent_id, expected):
('tag_2', 'tag_1', False), # Parent don't change
('tag_2', 'tag_3', True), # Valid
('tag_1', None, False), # Both parent id are None
- ('tag_1', 'tag_3', True), # Valid
+ ('tag_1', 'tag_3', True), # Valid
)
@ddt.unpack
- def test_applies_for(self, tag_id, parent_id, expected):
+ def test_applies_for(self, tag_id: str, parent_id: str | None, expected: bool):
result = UpdateParentTag.applies_for(
taxonomy=self.taxonomy,
tag=TagItem(
@@ -332,7 +336,7 @@ def test_applies_for(self, tag_id, parent_id, expected):
('tag_2', 'tag_10', 0), # Valid
)
@ddt.unpack
- def test_validate(self, tag_id, parent_id, expected):
+ def test_validate(self, tag_id: str, parent_id: str | None, expected: int):
action = UpdateParentTag(
taxonomy=self.taxonomy,
tag=TagItem(
@@ -385,7 +389,7 @@ class TestRenameTag(TestImportActionMixin, TestCase):
('tag_1', 'Tag 1 v2', True), # Valid
)
@ddt.unpack
- def test_applies_for(self, tag_id, value, expected):
+ def test_applies_for(self, tag_id: str, value: str, expected: bool):
result = RenameTag.applies_for(
taxonomy=self.taxonomy,
tag=TagItem(
@@ -403,7 +407,7 @@ def test_applies_for(self, tag_id, value, expected):
('Tag 12', 0), # Valid
)
@ddt.unpack
- def test_validate(self, value, expected):
+ def test_validate(self, value: str, expected: int):
action = RenameTag(
taxonomy=self.taxonomy,
tag=TagItem(
@@ -416,7 +420,7 @@ def test_validate(self, value, expected):
errors = action.validate(self.indexed_actions)
self.assertEqual(len(errors), expected)
- def test_execute(self):
+ def test_execute(self) -> None:
tag_id = 'tag_1'
value = 'Tag 1 V2'
tag_item = TagItem(
@@ -440,10 +444,10 @@ class TestDeleteTag(TestImportActionMixin, TestCase):
Test for 'delete' action
"""
- def test_applies_for(self):
+ def test_applies_for(self) -> None:
assert not DeleteTag.applies_for(self.taxonomy, None)
- def test_validate(self):
+ def test_validate(self) -> None:
action = DeleteTag(
taxonomy=self.taxonomy,
tag=TagItem(
@@ -455,7 +459,7 @@ def test_validate(self):
)
assert not action.validate(self.indexed_actions)
- def test_execute(self):
+ def test_execute(self) -> None:
tag_id = 'tag_3'
tag_item = TagItem(
id=tag_id,
@@ -475,7 +479,7 @@ class TestWithoutChanges(TestImportActionMixin, TestCase):
"""
Test for 'without_changes' action
"""
- def test_applies_for(self):
+ def test_applies_for(self) -> None:
result = WithoutChanges.applies_for(
self.taxonomy,
tag=TagItem(
@@ -486,7 +490,7 @@ def test_applies_for(self):
)
self.assertFalse(result)
- def test_validate(self):
+ def test_validate(self) -> None:
action = WithoutChanges(
taxonomy=self.taxonomy,
tag=TagItem(
diff --git a/tests/openedx_tagging/core/tagging/import_export/test_api.py b/tests/openedx_tagging/core/tagging/import_export/test_api.py
index 453979e01..3fa1d46cf 100644
--- a/tests/openedx_tagging/core/tagging/import_export/test_api.py
+++ b/tests/openedx_tagging/core/tagging/import_export/test_api.py
@@ -6,14 +6,9 @@
from django.test.testcases import TestCase
-from openedx_tagging.core.tagging.models import (
- TagImportTask,
- TagImportTaskState,
- Taxonomy,
- LanguageTaxonomy,
-)
-from openedx_tagging.core.tagging.import_export import ParserFormat
import openedx_tagging.core.tagging.import_export.api as import_export_api
+from openedx_tagging.core.tagging.import_export import ParserFormat
+from openedx_tagging.core.tagging.models import LanguageTaxonomy, TagImportTask, TagImportTaskState, Taxonomy
from .mixins import TestImportExportMixin
@@ -23,7 +18,7 @@ class TestImportExportApi(TestImportExportMixin, TestCase):
Test import/export API functions
"""
- def setUp(self):
+ def setUp(self) -> None:
self.tags = [
{"id": "tag_31", "value": "Tag 31"},
{"id": "tag_32", "value": "Tag 32"},
@@ -39,8 +34,8 @@ def setUp(self):
]}
self.invalid_parser_file = BytesIO(json.dumps(json_data).encode())
json_data = {"tags": [
- {'id': 'tag_31', 'value': 'Tag 31',},
- {'id': 'tag_31', 'value': 'Tag 32',},
+ {'id': 'tag_31', 'value': 'Tag 31'},
+ {'id': 'tag_31', 'value': 'Tag 32'},
]}
self.invalid_plan_file = BytesIO(json.dumps(json_data).encode())
@@ -57,17 +52,17 @@ def setUp(self):
self.system_taxonomy = self.system_taxonomy.cast()
return super().setUp()
- def test_check_status(self):
+ def test_check_status(self) -> None:
TagImportTask.create(self.taxonomy)
status = import_export_api.get_last_import_status(self.taxonomy)
- assert status == TagImportTaskState.LOADING_DATA.value
+ assert status == TagImportTaskState.LOADING_DATA
- def test_check_log(self):
+ def test_check_log(self) -> None:
TagImportTask.create(self.taxonomy)
log = import_export_api.get_last_import_log(self.taxonomy)
assert "Import task created" in log
- def test_invalid_import_tags(self):
+ def test_invalid_import_tags(self) -> None:
TagImportTask.create(self.taxonomy)
with self.assertRaises(ValueError):
# Raise error if there is a current in progress task
@@ -77,7 +72,7 @@ def test_invalid_import_tags(self):
self.parser_format,
)
- def test_import_export_validations(self):
+ def test_import_export_validations(self) -> None:
# Check that import is invalid with open taxonomy
with self.assertRaises(NotImplementedError):
import_export_api.import_tags(
@@ -94,7 +89,7 @@ def test_import_export_validations(self):
self.parser_format,
)
- def test_with_python_error(self):
+ def test_with_python_error(self) -> None:
self.file.close()
assert not import_export_api.import_tags(
self.taxonomy,
@@ -103,10 +98,10 @@ def test_with_python_error(self):
)
status = import_export_api.get_last_import_status(self.taxonomy)
log = import_export_api.get_last_import_log(self.taxonomy)
- assert status == TagImportTaskState.ERROR.value
+ assert status == TagImportTaskState.ERROR
assert "ValueError('I/O operation on closed file.')" in log
- def test_with_parser_error(self):
+ def test_with_parser_error(self) -> None:
assert not import_export_api.import_tags(
self.taxonomy,
self.invalid_parser_file,
@@ -114,11 +109,11 @@ def test_with_parser_error(self):
)
status = import_export_api.get_last_import_status(self.taxonomy)
log = import_export_api.get_last_import_log(self.taxonomy)
- assert status == TagImportTaskState.ERROR.value
+ assert status == TagImportTaskState.ERROR
assert "Starting to load data from file" in log
assert "Invalid '.json' format" in log
- def test_with_plan_errors(self):
+ def test_with_plan_errors(self) -> None:
assert not import_export_api.import_tags(
self.taxonomy,
self.invalid_plan_file,
@@ -126,14 +121,14 @@ def test_with_plan_errors(self):
)
status = import_export_api.get_last_import_status(self.taxonomy)
log = import_export_api.get_last_import_log(self.taxonomy)
- assert status == TagImportTaskState.ERROR.value
+ assert status == TagImportTaskState.ERROR
assert "Starting to load data from file" in log
assert "Load data finished" in log
assert "Starting plan actions" in log
assert "Plan finished" in log
assert "Conflict with 'create'" in log
- def test_valid(self):
+ def test_valid(self) -> None:
assert import_export_api.import_tags(
self.taxonomy,
self.file,
@@ -142,7 +137,7 @@ def test_valid(self):
)
status = import_export_api.get_last_import_status(self.taxonomy)
log = import_export_api.get_last_import_log(self.taxonomy)
- assert status == TagImportTaskState.SUCCESS.value
+ assert status == TagImportTaskState.SUCCESS
assert "Starting to load data from file" in log
assert "Load data finished" in log
assert "Starting plan actions" in log
@@ -150,7 +145,7 @@ def test_valid(self):
assert "Starting execute actions" in log
assert "Execution finished" in log
- def test_start_task_after_error(self):
+ def test_start_task_after_error(self) -> None:
assert not import_export_api.import_tags(
self.taxonomy,
self.invalid_parser_file,
@@ -162,7 +157,7 @@ def test_start_task_after_error(self):
self.parser_format,
)
- def test_start_task_after_success(self):
+ def test_start_task_after_success(self) -> None:
assert import_export_api.import_tags(
self.taxonomy,
self.file,
@@ -179,7 +174,7 @@ def test_start_task_after_success(self):
self.parser_format,
)
- def test_export_validations(self):
+ def test_export_validations(self) -> None:
# Check that import is invalid with open taxonomy
with self.assertRaises(NotImplementedError):
import_export_api.export_tags(
@@ -194,7 +189,7 @@ def test_export_validations(self):
self.parser_format,
)
- def test_import_with_export_output(self):
+ def test_import_with_export_output(self) -> None:
for parser_format in ParserFormat:
output = import_export_api.export_tags(
self.taxonomy,
@@ -215,5 +210,5 @@ def test_import_with_export_output(self):
new_tag = new_taxonomy.tag_set.get(external_id=tag.external_id)
assert new_tag.value == tag.value
if tag.parent:
+ assert new_tag.parent
assert tag.parent.external_id == new_tag.parent.external_id
-
\ No newline at end of file
diff --git a/tests/openedx_tagging/core/tagging/import_export/test_import_plan.py b/tests/openedx_tagging/core/tagging/import_export/test_import_plan.py
index af1e55f30..915ffb724 100644
--- a/tests/openedx_tagging/core/tagging/import_export/test_import_plan.py
+++ b/tests/openedx_tagging/core/tagging/import_export/test_import_plan.py
@@ -1,39 +1,39 @@
"""
Test for import_plan functions
"""
-import ddt
-
+import ddt # type: ignore[import]
from django.test.testcases import TestCase
-from openedx_tagging.core.tagging.import_export.import_plan import TagItem, TagImportPlan
from openedx_tagging.core.tagging.import_export.actions import CreateTag
from openedx_tagging.core.tagging.import_export.exceptions import TagImportError
+from openedx_tagging.core.tagging.import_export.import_plan import TagImportPlan, TagItem
+
from .test_actions import TestImportActionMixin
+
@ddt.ddt
class TestTagImportPlan(TestImportActionMixin, TestCase):
"""
Test for import plan functions
"""
- def setUp(self):
+ def setUp(self) -> None:
super().setUp()
self.import_plan = TagImportPlan(self.taxonomy)
- def test_tag_import_error(self):
+ def test_tag_import_error(self) -> None:
message = "Error message"
expected_repr = f"TagImportError({message})"
error = TagImportError(message)
assert str(error) == message
assert repr(error) == expected_repr
-
@ddt.data(
- ('tag_10', 1), # Test invalid
- ('tag_30', 0), # Test valid
+ ('tag_10', 1), # Test invalid
+ ('tag_30', 0), # Test valid
)
@ddt.unpack
- def test_build_action(self, tag_id, errors_expected):
+ def test_build_action(self, tag_id: str, errors_expected: int):
self.import_plan.indexed_actions = self.indexed_actions
self.import_plan._build_action( # pylint: disable=protected-access
CreateTag,
@@ -48,7 +48,7 @@ def test_build_action(self, tag_id, errors_expected):
assert self.import_plan.actions[0].name == 'create'
assert self.import_plan.indexed_actions['create'][1].tag.id == tag_id
- def test_build_delete_actions(self):
+ def test_build_delete_actions(self) -> None:
tags = {
tag.external_id: tag
for tag in self.taxonomy.tag_set.exclude(pk=25)
@@ -79,133 +79,142 @@ def test_build_delete_actions(self):
assert self.import_plan.actions[4].tag.id == 'tag_3'
@ddt.data(
- ([
- {
- 'id': 'tag_31',
- 'value': 'Tag 31',
- },
- {
- 'id': 'tag_32',
- 'value': 'Tag 32',
- 'parent_id': 'tag_1',
- },
- {
- 'id': 'tag_2',
- 'value': 'Tag 2 v2',
- 'parent_id': 'tag_1'
- },
- {
- 'id': 'tag_4',
- 'value': 'Tag 4 v2',
- 'parent_id': 'tag_1',
- },
- {
- 'id': 'tag_1',
- 'value': 'Tag 1',
- },
- ],
- False,
- 0,
- [
- {
- 'name': 'create',
- 'id': 'tag_31'
- },
- {
- 'name': 'create',
- 'id': 'tag_32'
- },
- {
- 'name': 'rename',
- 'id': 'tag_2'
- },
- {
- 'name': 'update_parent',
- 'id': 'tag_4'
- },
- {
- 'name': 'rename',
- 'id': 'tag_4'
- },
- {
- 'name': 'without_changes',
- 'id': 'tag_1'
- },
- ]), # Test valid actions
- ([
- {
- 'id': 'tag_31',
- 'value': 'Tag 31',
- },
- {
- 'id': 'tag_31',
- 'value': 'Tag 32',
- },
- {
- 'id': 'tag_1',
- 'value': 'Tag 2',
- },
- {
- 'id': 'tag_4',
- 'value': 'Tag 4',
- 'parent_id': 'tag_100',
- },
- ],
- False,
- 3,
- [
- {
- 'name': 'create',
- 'id': 'tag_31',
- },
- {
- 'name': 'create',
- 'id': 'tag_31',
- },
- {
- 'name': 'rename',
- 'id': 'tag_1',
- },
- {
- 'name': 'update_parent',
- 'id': 'tag_4',
- }
- ]), # Test with errors in actions
- ([
- {
- 'id': 'tag_4',
- 'value': 'Tag 4',
- 'parent_id': 'tag_3',
- },
- ],
- True,
- 0,
- [
- {
- 'name': 'without_changes',
- 'id': 'tag_4',
- },
- {
- 'name': 'update_parent',
- 'id': 'tag_2',
- },
- {
- 'name': 'delete',
- 'id': 'tag_1',
- },
- {
- 'name': 'delete',
- 'id': 'tag_2',
- },
- {
- 'name': 'update_parent',
- 'id': 'tag_4',
- },
- {
- 'name': 'delete',
- 'id': 'tag_3',
- },
- ]) # Test with deletes (replace=True)
+ # Test valid actions
+ (
+ [
+ {
+ 'id': 'tag_31',
+ 'value': 'Tag 31',
+ },
+ {
+ 'id': 'tag_32',
+ 'value': 'Tag 32',
+ 'parent_id': 'tag_1',
+ },
+ {
+ 'id': 'tag_2',
+ 'value': 'Tag 2 v2',
+ 'parent_id': 'tag_1'
+ },
+ {
+ 'id': 'tag_4',
+ 'value': 'Tag 4 v2',
+ 'parent_id': 'tag_1',
+ },
+ {
+ 'id': 'tag_1',
+ 'value': 'Tag 1',
+ },
+ ],
+ False,
+ 0,
+ [
+ {
+ 'name': 'create',
+ 'id': 'tag_31'
+ },
+ {
+ 'name': 'create',
+ 'id': 'tag_32'
+ },
+ {
+ 'name': 'rename',
+ 'id': 'tag_2'
+ },
+ {
+ 'name': 'update_parent',
+ 'id': 'tag_4'
+ },
+ {
+ 'name': 'rename',
+ 'id': 'tag_4'
+ },
+ {
+ 'name': 'without_changes',
+ 'id': 'tag_1'
+ },
+ ]
+ ),
+ # Test with errors in actions
+ (
+ [
+ {
+ 'id': 'tag_31',
+ 'value': 'Tag 31',
+ },
+ {
+ 'id': 'tag_31',
+ 'value': 'Tag 32',
+ },
+ {
+ 'id': 'tag_1',
+ 'value': 'Tag 2',
+ },
+ {
+ 'id': 'tag_4',
+ 'value': 'Tag 4',
+ 'parent_id': 'tag_100',
+ },
+ ],
+ False,
+ 3,
+ [
+ {
+ 'name': 'create',
+ 'id': 'tag_31',
+ },
+ {
+ 'name': 'create',
+ 'id': 'tag_31',
+ },
+ {
+ 'name': 'rename',
+ 'id': 'tag_1',
+ },
+ {
+ 'name': 'update_parent',
+ 'id': 'tag_4',
+ }
+ ]
+ ),
+ # Test with deletes (replace=True)
+ (
+ [
+ {
+ 'id': 'tag_4',
+ 'value': 'Tag 4',
+ 'parent_id': 'tag_3',
+ },
+ ],
+ True,
+ 0,
+ [
+ {
+ 'name': 'without_changes',
+ 'id': 'tag_4',
+ },
+ {
+ 'name': 'update_parent',
+ 'id': 'tag_2',
+ },
+ {
+ 'name': 'delete',
+ 'id': 'tag_1',
+ },
+ {
+ 'name': 'delete',
+ 'id': 'tag_2',
+ },
+ {
+ 'name': 'update_parent',
+ 'id': 'tag_4',
+ },
+ {
+ 'name': 'delete',
+ 'id': 'tag_3',
+ },
+ ]
+ )
)
@ddt.unpack
def test_generate_actions(self, tags, replace, expected_errors, expected_actions):
@@ -220,109 +229,115 @@ def test_generate_actions(self, tags, replace, expected_errors, expected_actions
assert self.import_plan.actions[index].index == index + 1
@ddt.data(
- ([
- {
- 'id': 'tag_31',
- 'value': 'Tag 31',
- },
- {
- 'id': 'tag_31',
- 'value': 'Tag 32',
- },
- {
- 'id': 'tag_1',
- 'value': 'Tag 2',
- },
- {
- 'id': 'tag_4',
- 'value': 'Tag 4',
- 'parent_id': 'tag_100',
- },
- {
- 'id': 'tag_33',
- 'value': 'Tag 32',
- },
- {
- 'id': 'tag_2',
- 'value': 'Tag 31',
- },
- ],
- False,
- "Import plan for Import Taxonomy Test\n"
- "--------------------------------\n"
- "#1: Create a new tag with values (external_id=tag_31, value=Tag 31, parent_id=None).\n"
- "#2: Create a new tag with values (external_id=tag_31, value=Tag 32, parent_id=None).\n"
- "#3: Rename tag value of tag (external_id=tag_1) from 'Tag 1' to 'Tag 2'\n"
- "#4: Update the parent of tag (external_id=tag_4) from parent (external_id=tag_3) "
- "to parent (external_id=tag_100).\n"
- "#5: Create a new tag with values (external_id=tag_33, value=Tag 32, parent_id=None).\n"
- "#6: Update the parent of tag (external_id=tag_2) from parent (external_id=tag_1) "
- "to parent (external_id=None).\n"
- "#7: Rename tag value of tag (external_id=tag_2) from 'Tag 2' to 'Tag 31'\n"
- "\nOutput errors\n"
- "--------------------------------\n"
- "Conflict with 'create' (#2) and action #1: Duplicated external_id tag.\n"
- "Action error in 'rename' (#3): Duplicated tag value with tag in database (external_id=tag_2).\n"
- "Action error in 'update_parent' (#4): Unknown parent tag (tag_100). "
- "You need to add parent before the child in your file.\n"
- "Conflict with 'create' (#5) and action #2: Duplicated tag value.\n"
- "Conflict with 'rename' (#7) and action #1: Duplicated tag value.\n"
- ), # Testing plan with errors
- ([
- {
- 'id': 'tag_31',
- 'value': 'Tag 31',
- },
- {
- 'id': 'tag_32',
- 'value': 'Tag 32',
- 'parent_id': 'tag_1',
- },
- {
- 'id': 'tag_2',
- 'value': 'Tag 2 v2',
- 'parent_id': 'tag_1'
- },
- {
- 'id': 'tag_4',
- 'value': 'Tag 4 v2',
- 'parent_id': 'tag_1',
- },
- {
- 'id': 'tag_1',
- 'value': 'Tag 1',
- },
- ],
- False,
- "Import plan for Import Taxonomy Test\n"
- "--------------------------------\n"
- "#1: Create a new tag with values (external_id=tag_31, value=Tag 31, parent_id=None).\n"
- "#2: Create a new tag with values (external_id=tag_32, value=Tag 32, parent_id=tag_1).\n"
- "#3: Rename tag value of tag (external_id=tag_2) from 'Tag 2' to 'Tag 2 v2'\n"
- "#4: Update the parent of tag (external_id=tag_4) from parent (external_id=tag_3) "
- "to parent (external_id=tag_1).\n"
- "#5: Rename tag value of tag (external_id=tag_4) from 'Tag 4' to 'Tag 4 v2'\n"
- "#6: No changes needed for tag (external_id=tag_1)\n"
- ), # Testing valid plan
- ([
- {
- 'id': 'tag_4',
- 'value': 'Tag 4',
- 'parent_id': 'tag_3',
- },
- ],
- True,
- "Import plan for Import Taxonomy Test\n"
- "--------------------------------\n"
- "#1: No changes needed for tag (external_id=tag_4)\n"
- "#2: Update the parent of tag (external_id=tag_2) from parent (external_id=tag_1) "
- "to parent (external_id=None).\n"
- "#3: Delete tag (external_id=tag_1)\n"
- "#4: Delete tag (external_id=tag_2)\n"
- "#5: Update the parent of tag (external_id=tag_4) from parent (external_id=tag_3) "
- "to parent (external_id=None).\n"
- "#6: Delete tag (external_id=tag_3)\n"
- ) # Testing deletes (replace=True)
+ # Testing plan with errors
+ (
+ [
+ {
+ 'id': 'tag_31',
+ 'value': 'Tag 31',
+ },
+ {
+ 'id': 'tag_31',
+ 'value': 'Tag 32',
+ },
+ {
+ 'id': 'tag_1',
+ 'value': 'Tag 2',
+ },
+ {
+ 'id': 'tag_4',
+ 'value': 'Tag 4',
+ 'parent_id': 'tag_100',
+ },
+ {
+ 'id': 'tag_33',
+ 'value': 'Tag 32',
+ },
+ {
+ 'id': 'tag_2',
+ 'value': 'Tag 31',
+ },
+ ],
+ False,
+ "Import plan for Import Taxonomy Test\n"
+ "--------------------------------\n"
+ "#1: Create a new tag with values (external_id=tag_31, value=Tag 31, parent_id=None).\n"
+ "#2: Create a new tag with values (external_id=tag_31, value=Tag 32, parent_id=None).\n"
+ "#3: Rename tag value of tag (external_id=tag_1) from 'Tag 1' to 'Tag 2'\n"
+ "#4: Update the parent of tag (external_id=tag_4) from parent (external_id=tag_3) "
+ "to parent (external_id=tag_100).\n"
+ "#5: Create a new tag with values (external_id=tag_33, value=Tag 32, parent_id=None).\n"
+ "#6: Update the parent of tag (external_id=tag_2) from parent (external_id=tag_1) "
+ "to parent (external_id=None).\n"
+ "#7: Rename tag value of tag (external_id=tag_2) from 'Tag 2' to 'Tag 31'\n"
+ "\nOutput errors\n"
+ "--------------------------------\n"
+ "Conflict with 'create' (#2) and action #1: Duplicated external_id tag.\n"
+ "Action error in 'rename' (#3): Duplicated tag value with tag in database (external_id=tag_2).\n"
+ "Action error in 'update_parent' (#4): Unknown parent tag (tag_100). "
+ "You need to add parent before the child in your file.\n"
+ "Conflict with 'create' (#5) and action #2: Duplicated tag value.\n"
+ "Conflict with 'rename' (#7) and action #1: Duplicated tag value.\n"
+ ),
+ # Testing valid plan
+ (
+ [
+ {
+ 'id': 'tag_31',
+ 'value': 'Tag 31',
+ },
+ {
+ 'id': 'tag_32',
+ 'value': 'Tag 32',
+ 'parent_id': 'tag_1',
+ },
+ {
+ 'id': 'tag_2',
+ 'value': 'Tag 2 v2',
+ 'parent_id': 'tag_1'
+ },
+ {
+ 'id': 'tag_4',
+ 'value': 'Tag 4 v2',
+ 'parent_id': 'tag_1',
+ },
+ {
+ 'id': 'tag_1',
+ 'value': 'Tag 1',
+ },
+ ],
+ False,
+ "Import plan for Import Taxonomy Test\n"
+ "--------------------------------\n"
+ "#1: Create a new tag with values (external_id=tag_31, value=Tag 31, parent_id=None).\n"
+ "#2: Create a new tag with values (external_id=tag_32, value=Tag 32, parent_id=tag_1).\n"
+ "#3: Rename tag value of tag (external_id=tag_2) from 'Tag 2' to 'Tag 2 v2'\n"
+ "#4: Update the parent of tag (external_id=tag_4) from parent (external_id=tag_3) "
+ "to parent (external_id=tag_1).\n"
+ "#5: Rename tag value of tag (external_id=tag_4) from 'Tag 4' to 'Tag 4 v2'\n"
+ "#6: No changes needed for tag (external_id=tag_1)\n"
+ ),
+ # Testing deletes (replace=True)
+ (
+ [
+ {
+ 'id': 'tag_4',
+ 'value': 'Tag 4',
+ 'parent_id': 'tag_3',
+ },
+ ],
+ True,
+ "Import plan for Import Taxonomy Test\n"
+ "--------------------------------\n"
+ "#1: No changes needed for tag (external_id=tag_4)\n"
+ "#2: Update the parent of tag (external_id=tag_2) from parent (external_id=tag_1) "
+ "to parent (external_id=None).\n"
+ "#3: Delete tag (external_id=tag_1)\n"
+ "#4: Delete tag (external_id=tag_2)\n"
+ "#5: Update the parent of tag (external_id=tag_4) from parent (external_id=tag_3) "
+ "to parent (external_id=None).\n"
+ "#6: Delete tag (external_id=tag_3)\n"
+ ),
)
@ddt.unpack
def test_plan(self, tags, replace, expected):
@@ -339,38 +354,46 @@ def test_plan(self, tags, replace, expected):
assert plan == expected
@ddt.data(
- ([
- {
- 'id': 'tag_31',
- 'value': 'Tag 31',
- },
- {
- 'id': 'tag_32',
- 'value': 'Tag 32',
- 'parent_id': 'tag_1',
- },
- {
- 'id': 'tag_2',
- 'value': 'Tag 2 v2',
- 'parent_id': 'tag_1'
- },
- {
- 'id': 'tag_4',
- 'value': 'Tag 4 v2',
- 'parent_id': 'tag_1',
- },
- {
- 'id': 'tag_1',
- 'value': 'Tag 1',
- },
- ], False), # Testing all actions
- ([
- {
- 'id': 'tag_4',
- 'value': 'Tag 4',
- 'parent_id': 'tag_3',
- },
- ], True), # Testing deletes (replace=True)
+ # Testing all actions
+ (
+ [
+ {
+ 'id': 'tag_31',
+ 'value': 'Tag 31',
+ },
+ {
+ 'id': 'tag_32',
+ 'value': 'Tag 32',
+ 'parent_id': 'tag_1',
+ },
+ {
+ 'id': 'tag_2',
+ 'value': 'Tag 2 v2',
+ 'parent_id': 'tag_1'
+ },
+ {
+ 'id': 'tag_4',
+ 'value': 'Tag 4 v2',
+ 'parent_id': 'tag_1',
+ },
+ {
+ 'id': 'tag_1',
+ 'value': 'Tag 1',
+ },
+ ],
+ False,
+ ),
+ # Testing deletes (replace=True)
+ (
+ [
+ {
+ 'id': 'tag_4',
+ 'value': 'Tag 4',
+ 'parent_id': 'tag_3',
+ },
+ ],
+ True,
+ ),
)
@ddt.unpack
def test_execute(self, tags, replace):
@@ -415,4 +438,3 @@ def test_error_in_execute(self):
assert not self.taxonomy.tag_set.filter(external_id=created_tag).exists()
assert not self.import_plan.execute()
assert not self.taxonomy.tag_set.filter(external_id=created_tag).exists()
-
\ No newline at end of file
diff --git a/tests/openedx_tagging/core/tagging/import_export/test_parsers.py b/tests/openedx_tagging/core/tagging/import_export/test_parsers.py
index 5b77e3a54..3bcd2667c 100644
--- a/tests/openedx_tagging/core/tagging/import_export/test_parsers.py
+++ b/tests/openedx_tagging/core/tagging/import_export/test_parsers.py
@@ -1,23 +1,18 @@
"""
Test for import/export parsers
"""
-from io import BytesIO
+from __future__ import annotations
+
import json
-import ddt
+from io import BytesIO
+import ddt # type: ignore[import]
from django.test.testcases import TestCase
-from openedx_tagging.core.tagging.import_export.parsers import (
- Parser,
- get_parser,
- JSONParser,
- CSVParser,
- ParserFormat,
-)
-from openedx_tagging.core.tagging.import_export.exceptions import (
- TagParserError,
-)
+from openedx_tagging.core.tagging.import_export.exceptions import TagParserError
+from openedx_tagging.core.tagging.import_export.parsers import CSVParser, JSONParser, Parser, ParserFormat, get_parser
from openedx_tagging.core.tagging.models import Taxonomy
+
from .mixins import TestImportExportMixin
@@ -26,16 +21,16 @@ class TestParser(TestCase):
Test for general parser functions
"""
- def test_get_parser(self):
+ def test_get_parser(self) -> None:
for parser_format in ParserFormat:
parser = get_parser(parser_format)
self.assertEqual(parser.format, parser_format)
- def test_parser_not_found(self):
+ def test_parser_not_found(self) -> None:
with self.assertRaises(ValueError):
- get_parser(None)
+ get_parser(None) # type: ignore[arg-type]
- def test_not_implemented(self):
+ def test_not_implemented(self) -> None:
taxonomy = Taxonomy(name="Test taxonomy")
taxonomy.save()
with self.assertRaises(NotImplementedError):
@@ -43,7 +38,7 @@ def test_not_implemented(self):
with self.assertRaises(NotImplementedError):
Parser.export(taxonomy)
- def test_tag_parser_error(self):
+ def test_tag_parser_error(self) -> None:
tag = {"id": 'tag_id', "value": "tag_value"}
expected_str = f"Import parser error on {tag}"
expected_repr = f"TagParserError(Import parser error on {tag})"
@@ -58,7 +53,7 @@ class TestJSONParser(TestImportExportMixin, TestCase):
Test for .json parser
"""
- def test_invalid_json(self):
+ def test_invalid_json(self) -> None:
json_data = "{This is an invalid json}"
json_file = BytesIO(json_data.encode())
tags, errors = JSONParser.parse_import(json_file)
@@ -66,7 +61,7 @@ def test_invalid_json(self):
assert len(errors) == 1
assert "Expecting property name enclosed in double quotes" in str(errors[0])
- def test_load_data_errors(self):
+ def test_load_data_errors(self) -> None:
json_data = {"invalid": [
{"id": "tag_1", "name": "Tag 1"},
]}
@@ -84,7 +79,7 @@ def test_load_data_errors(self):
@ddt.data(
(
{"tags": [
- {"id": "tag_1", "value": "Tag 1"}, # Valid
+ {"id": "tag_1", "value": "Tag 1"}, # Valid
]},
[]
),
@@ -105,7 +100,7 @@ def test_load_data_errors(self):
{"tags": [
{"id": "", "value": "tag 1"},
{"id": "tag_2", "value": ""},
- {"id": "tag_3", "value": "tag 3", "parent_id": ""}, # Valid
+ {"id": "tag_3", "value": "tag 3", "parent_id": ""}, # Valid
]},
[
"Empty 'id' field on {'id': '', 'value': 'tag 1'}",
@@ -114,7 +109,7 @@ def test_load_data_errors(self):
)
)
@ddt.unpack
- def test_parse_tags_errors(self, json_data, expected_errors):
+ def test_parse_tags_errors(self, json_data: dict, expected_errors: list[str]):
json_file = BytesIO(json.dumps(json_data).encode())
_, errors = JSONParser.parse_import(json_file)
@@ -123,7 +118,7 @@ def test_parse_tags_errors(self, json_data, expected_errors):
for error in errors:
self.assertIn(str(error), expected_errors)
- def test_parse_tags(self):
+ def test_parse_tags(self) -> None:
expected_tags = [
{"id": "tag_1", "value": "tag 1"},
{"id": "tag_2", "value": "tag 2"},
@@ -157,7 +152,7 @@ def test_parse_tags(self):
index + JSONParser.inital_row
)
- def test_export_data(self):
+ def test_export_data(self) -> None:
result = JSONParser.export(self.taxonomy)
tags = json.loads(result).get("tags")
assert len(tags) == self.taxonomy.tag_set.count()
@@ -167,7 +162,7 @@ def test_export_data(self):
if tag.get("parent_id"):
assert tag.get("parent_id") == taxonomy_tag.parent.external_id
- def test_import_with_export_output(self):
+ def test_import_with_export_output(self) -> None:
output = JSONParser.export(self.taxonomy)
json_file = BytesIO(output.encode())
tags, errors = JSONParser.parse_import(json_file)
@@ -176,7 +171,6 @@ def test_import_with_export_output(self):
self.assertEqual(len(errors), 0)
self.assertEqual(len(tags), len(output_tags))
-
for tag in tags:
output_tag = None
for out_tag in output_tags:
@@ -218,7 +212,7 @@ class TestCSVParser(TestImportExportMixin, TestCase):
)
)
@ddt.unpack
- def test_load_data_errors(self, csv_data, expected_errors):
+ def test_load_data_errors(self, csv_data: str, expected_errors: list[str]):
csv_file = BytesIO(csv_data.encode())
tags, errors = CSVParser.parse_import(csv_file)
@@ -237,7 +231,7 @@ def test_load_data_errors(self, csv_data, expected_errors):
]
),
(
- "id,value\ntag_1,tag 1\n", # Valid
+ "id,value\ntag_1,tag 1\n", # Valid
[]
)
)
@@ -263,7 +257,7 @@ def _build_csv(self, tags):
)
return csv
- def test_parse_tags(self):
+ def test_parse_tags(self) -> None:
expected_tags = [
{"id": "tag_1", "value": "tag 1"},
{"id": "tag_2", "value": "tag 2"},
@@ -296,7 +290,7 @@ def test_parse_tags(self):
index + CSVParser.inital_row
)
- def test_import_with_export_output(self):
+ def test_import_with_export_output(self) -> None:
output = CSVParser.export(self.taxonomy)
csv_file = BytesIO(output.encode())
tags, errors = CSVParser.parse_import(csv_file)
diff --git a/tests/openedx_tagging/core/tagging/import_export/test_tasks.py b/tests/openedx_tagging/core/tagging/import_export/test_tasks.py
index 4b88fc6d6..c3c3e3764 100644
--- a/tests/openedx_tagging/core/tagging/import_export/test_tasks.py
+++ b/tests/openedx_tagging/core/tagging/import_export/test_tasks.py
@@ -6,8 +6,8 @@
from django.test.testcases import TestCase
-from openedx_tagging.core.tagging.import_export import ParserFormat
import openedx_tagging.core.tagging.import_export.tasks as import_export_tasks
+from openedx_tagging.core.tagging.import_export import ParserFormat
from .mixins import TestImportExportMixin
diff --git a/tests/openedx_tagging/core/tagging/test_api.py b/tests/openedx_tagging/core/tagging/test_api.py
index ae77d715b..0a19e276e 100644
--- a/tests/openedx_tagging/core/tagging/test_api.py
+++ b/tests/openedx_tagging/core/tagging/test_api.py
@@ -1,7 +1,12 @@
-""" Test the tagging APIs """
-import ddt
+"""
+Test the tagging APIs
+"""
+from __future__ import annotations
-from django.test.testcases import TestCase, override_settings
+from typing import Any
+
+import ddt # type: ignore[import]
+from django.test import TestCase, override_settings
import openedx_tagging.core.tagging.api as tagging_api
from openedx_tagging.core.tagging.models import ObjectTag, Tag, Taxonomy
@@ -22,9 +27,8 @@ class TestApiTagging(TestTagTaxonomyMixin, TestCase):
"""
Test the Tagging API methods.
"""
-
- def test_create_taxonomy(self):
- params = {
+ def test_create_taxonomy(self) -> None: # Note: we must specify '-> None' to opt in to type checking
+ params: dict[str, Any] = {
"name": "Difficulty",
"description": "This taxonomy contains tags describing the difficulty of an activity",
"enabled": False,
@@ -46,13 +50,13 @@ def test_bad_taxonomy_class(self):
)
assert "