",
+ }
+ ],
+ "migrated": 3,
+ }
+ result = sanitize_for_json(detail)
+ json_str = json.dumps(result)
+ parsed = json.loads(json_str)
+ self.assertEqual(parsed["migrated"], 3)
+ self.assertEqual(len(parsed["failures"]), 1)
+ self.assertIn("Sum", parsed["failures"][0]["file"])
+
+ def test_high_surrogate_handled(self):
+ """High surrogates (not from surrogateescape) are also handled."""
+ text = "test\ud800value"
+ result = sanitize_for_json(text)
+ json.dumps(result) # Must not raise
+ self.assertNotIn("\ud800", result)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/proc/wagtail_hooks.py b/proc/wagtail_hooks.py
index 5dd89466c..6fa6dfe63 100644
--- a/proc/wagtail_hooks.py
+++ b/proc/wagtail_hooks.py
@@ -217,6 +217,7 @@ class ArticleProcViewSet(CommonControlFieldViewSet):
list_display = [
"__str__",
+ "pid_status",
"migration_status",
"xml_status",
"sps_pkg_status",
@@ -226,6 +227,7 @@ class ArticleProcViewSet(CommonControlFieldViewSet):
]
list_filter = [
"collection",
+ "pid_status",
"migration_status",
"xml_status",
"sps_pkg_status",
diff --git a/production-v3.0.0rc4.yml b/production-v3.0.0rc4.yml
index 19182bd20..f281a3958 100644
--- a/production-v3.0.0rc4.yml
+++ b/production-v3.0.0rc4.yml
@@ -1,5 +1,5 @@
x-django-function: &django-function
- image: infrascielo/upload:${SCMS_WEBAPP_VERSION:-v2.10.4}
+ image: infrascielo/upload:${SCMS_WEBAPP_VERSION}
restart: unless-stopped
platform: linux/x86_64
depends_on:
@@ -48,9 +48,12 @@ services:
celeryworker:
<<: *django-function
- container_name: upload_production_celeryworker
command: /start-celeryworker
ports: []
+ deploy:
+ resources:
+ limits:
+ memory: 2.5G
celerybeat:
<<: *django-function
diff --git a/publication/api/journal.py b/publication/api/journal.py
index 9c15e0bfa..2c81e9415 100644
--- a/publication/api/journal.py
+++ b/publication/api/journal.py
@@ -1,4 +1,5 @@
import logging
+import re
from django.utils.translation import gettext_lazy as _
@@ -200,6 +201,20 @@ def add_sponsor(self, sponsor):
# Sponsors
self.data["sponsors"].append({"name": sponsor})
+ @staticmethod
+ def _clean_br_tags(text):
+ """
+ Replace
,
,
HTML tags with ", " and clean up
+ resulting duplicate commas and extra whitespace.
+ """
+ if not text:
+ return text
+ # Replace
variants (case insensitive) with ", "
+ text = re.sub(r"
", ", ", text, flags=re.IGNORECASE)
+ # Collapse sequences of commas and whitespace (e.g. ", , " -> ", ")
+ text = re.sub(r"(\s*,\s*)+", ", ", text)
+ return text.strip(", ")
+
def add_contact(self, name, email, address, city, state, country):
# email to contact
# self.data["editor_email"] = email
@@ -210,7 +225,7 @@ def add_contact(self, name, email, address, city, state, country):
# self.data["publisher_country"] = country
self.data["contact"] = {
"email": email,
- "address": address,
+ "address": self._clean_br_tags(address),
}
def add_logo_url(self, logo_url):
diff --git a/publication/api/publication.py b/publication/api/publication.py
index 974db5262..9e3196528 100644
--- a/publication/api/publication.py
+++ b/publication/api/publication.py
@@ -42,7 +42,8 @@ def __init__(
password=None,
timeout=None,
token=None,
- enabled=None
+ enabled=None,
+ verify=False,
):
self.timeout = timeout or 15
self.post_data_url = post_data_url
@@ -51,6 +52,7 @@ def __init__(
self.password = password
self.token = token
self.enabled = enabled
+ self.verify = verify
if not token and enabled:
self.get_token()
@@ -63,6 +65,7 @@ def data(self):
password=self.password,
token=self.token,
enabled=self.enabled,
+ verify=self.verify,
)
def post_data(self, payload, kwargs=None):
@@ -147,7 +150,7 @@ def _post_data(self, payload, token, kwargs=None):
data=json.dumps(payload),
headers=header,
timeout=self.timeout,
- verify=False,
+ verify=self.verify,
json=True,
)
diff --git a/publication/api/test_journal.py b/publication/api/test_journal.py
new file mode 100644
index 000000000..2d9f800bd
--- /dev/null
+++ b/publication/api/test_journal.py
@@ -0,0 +1,119 @@
+from unittest import TestCase
+
+from publication.api.journal import JournalPayload
+
+
+class JournalPayloadCleanBrTagsTest(TestCase):
+ def test_clean_br_tags_removes_br(self):
+ result = JournalPayload._clean_br_tags(
+ "PO Box 339,
Bloemfontein, Free State"
+ )
+ self.assertEqual(result, "PO Box 339, Bloemfontein, Free State")
+
+ def test_clean_br_tags_removes_br_self_closing(self):
+ result = JournalPayload._clean_br_tags(
+ "PO Box 339,
Bloemfontein, Free State"
+ )
+ self.assertEqual(result, "PO Box 339, Bloemfontein, Free State")
+
+ def test_clean_br_tags_removes_br_self_closing_with_space(self):
+ result = JournalPayload._clean_br_tags(
+ "PO Box 339,
Bloemfontein, Free State"
+ )
+ self.assertEqual(result, "PO Box 339, Bloemfontein, Free State")
+
+ def test_clean_br_tags_case_insensitive(self):
+ result = JournalPayload._clean_br_tags(
+ "PO Box 339,
Bloemfontein,
Free State"
+ )
+ self.assertEqual(result, "PO Box 339, Bloemfontein, Free State")
+
+ def test_clean_br_tags_without_surrounding_comma(self):
+ result = JournalPayload._clean_br_tags(
+ "Address Line 1
Address Line 2"
+ )
+ self.assertEqual(result, "Address Line 1, Address Line 2")
+
+ def test_clean_br_tags_multiple(self):
+ result = JournalPayload._clean_br_tags(
+ "Avenida Dr. Arnaldo, 715
01246-904 São Paulo SP Brazil
Tel./Fax: +55 11 3061-7985"
+ )
+ self.assertEqual(
+ result,
+ "Avenida Dr. Arnaldo, 715, 01246-904 São Paulo SP Brazil, Tel./Fax: +55 11 3061-7985",
+ )
+
+ def test_clean_br_tags_no_tags(self):
+ result = JournalPayload._clean_br_tags(
+ "Rua Leopoldo Bulhões, 1480, Rio de Janeiro"
+ )
+ self.assertEqual(result, "Rua Leopoldo Bulhões, 1480, Rio de Janeiro")
+
+ def test_clean_br_tags_none(self):
+ result = JournalPayload._clean_br_tags(None)
+ self.assertIsNone(result)
+
+ def test_clean_br_tags_empty(self):
+ result = JournalPayload._clean_br_tags("")
+ self.assertEqual(result, "")
+
+ def test_clean_br_tags_real_world_example(self):
+ """Test with the exact pattern from the issue screenshot."""
+ result = JournalPayload._clean_br_tags(
+ "Centre for Gender and Africa Studies, University of the Free State, "
+ "PO Box 339,
Bloemfontein, Free State, ZA, 9300, "
+ "
Tel: +27 (0)82 384 7027 - E-mail: henning.melber@nai.uu.se"
+ )
+ self.assertEqual(
+ result,
+ "Centre for Gender and Africa Studies, University of the Free State, "
+ "PO Box 339, Bloemfontein, Free State, ZA, 9300, "
+ "Tel: +27 (0)82 384 7027 - E-mail: henning.melber@nai.uu.se",
+ )
+
+
+class JournalPayloadAddContactTest(TestCase):
+ def test_add_contact_strips_br_from_address(self):
+ payload = {}
+ builder = JournalPayload(payload)
+ builder.add_contact(
+ name="Test Publisher",
+ email="test@example.com",
+ address="Street 1,
City,
Country",
+ city="City",
+ state="State",
+ country="Country",
+ )
+ self.assertEqual(
+ payload["contact"]["address"],
+ "Street 1, City, Country",
+ )
+
+ def test_add_contact_without_br(self):
+ payload = {}
+ builder = JournalPayload(payload)
+ builder.add_contact(
+ name="Test Publisher",
+ email="test@example.com",
+ address="Street 1, City, Country",
+ city="City",
+ state="State",
+ country="Country",
+ )
+ self.assertEqual(
+ payload["contact"]["address"],
+ "Street 1, City, Country",
+ )
+
+ def test_add_contact_none_address(self):
+ payload = {}
+ builder = JournalPayload(payload)
+ builder.add_contact(
+ name="Test Publisher",
+ email="test@example.com",
+ address=None,
+ city="City",
+ state="State",
+ country="Country",
+ )
+ self.assertIsNone(payload["contact"]["address"])
diff --git a/publication/migrations/0004_articleavailability_publication_rule_and_more.py b/publication/migrations/0004_alter_articleavailability_options_and_more.py
similarity index 52%
rename from publication/migrations/0004_articleavailability_publication_rule_and_more.py
rename to publication/migrations/0004_alter_articleavailability_options_and_more.py
index 837472d78..a0d0fb730 100644
--- a/publication/migrations/0004_articleavailability_publication_rule_and_more.py
+++ b/publication/migrations/0004_alter_articleavailability_options_and_more.py
@@ -1,4 +1,4 @@
-# Generated by Django 5.0.3 on 2025-04-24 15:12
+# Generated by Django 5.0.3 on 2025-05-14 15:12
from django.db import migrations, models
@@ -9,6 +9,13 @@ class Migration(migrations.Migration):
]
operations = [
+ migrations.AlterModelOptions(
+ name="articleavailability",
+ options={
+ "verbose_name": "Article availability",
+ "verbose_name_plural": "Article availability",
+ },
+ ),
migrations.AddField(
model_name="articleavailability",
name="publication_rule",
@@ -23,4 +30,15 @@ class Migration(migrations.Migration):
blank=True, max_length=30, null=True, verbose_name="published by"
),
),
+ migrations.AddField(
+ model_name="articleavailability",
+ name="published_percentage",
+ field=models.DecimalField(
+ decimal_places=2,
+ default=0.0,
+ help_text="0-100",
+ max_digits=5,
+ verbose_name="Published percentual",
+ ),
+ ),
]
diff --git a/publication/migrations/0005_alter_articleavailability_options_and_more.py b/publication/migrations/0005_alter_articleavailability_options_and_more.py
index 275861f79..d4376dd6f 100644
--- a/publication/migrations/0005_alter_articleavailability_options_and_more.py
+++ b/publication/migrations/0005_alter_articleavailability_options_and_more.py
@@ -5,10 +5,18 @@
class Migration(migrations.Migration):
dependencies = [
- ("publication", "0004_articleavailability_publication_rule_and_more"),
+ ("publication", "0004_alter_articleavailability_options_and_more"),
]
operations = [
+ migrations.AlterModelOptions(
+ name="articleavailability",
+ options={},
+ ),
+ migrations.RemoveField(
+ model_name="articleavailability",
+ name="published_percentage",
+ ),
migrations.AddField(
model_name="articleavailability",
name="completed",
diff --git a/publication/utils/document.py b/publication/utils/document.py
index 46c83563a..8331747ee 100644
--- a/publication/utils/document.py
+++ b/publication/utils/document.py
@@ -116,7 +116,11 @@ def get_contribs(self):
try:
affs = item.get("affs") or []
affiliation = ", ".join(
- [a.get("original") or a.get("orgname") for a in affs]
+ [
+ a.get("original") or a.get("orgname")
+ for a in affs
+ if a.get("original") or a.get("orgname")
+ ]
)
except KeyError:
affiliation = None
diff --git a/publication/utils/test_document.py b/publication/utils/test_document.py
new file mode 100644
index 000000000..3509c8d7b
--- /dev/null
+++ b/publication/utils/test_document.py
@@ -0,0 +1,112 @@
+from unittest import TestCase
+from unittest.mock import MagicMock
+
+from lxml import etree
+
+from publication.utils.document import XMLArticle
+
+
+def _create_xml_article(xml_string):
+ xmltree = etree.fromstring(xml_string)
+ xml_with_pre = MagicMock()
+ xml_with_pre.xmltree = xmltree
+ return XMLArticle(xml_with_pre)
+
+
+class XMLArticleGetContribsTest(TestCase):
+ def test_get_contribs_with_affiliation_missing_original_and_orgname(self):
+ """Regression test: affiliations lacking both 'original' and 'orgname'
+ should not raise TypeError in str.join()."""
+ xml_string = """
+
+
+
+
+
+ Silva
+ Rafaela
+
+
+
+
+
+
+
+
+
+ """
+
+ article_xml = _create_xml_article(xml_string)
+ result = article_xml.get_contribs()
+
+ self.assertEqual(len(result["names"]), 1)
+ self.assertEqual(result["names"][0]["surname"], "Silva")
+ self.assertEqual(result["names"][0]["given_names"], "Rafaela")
+ self.assertEqual(result["names"][0]["affiliation"], "")
+
+ def test_get_contribs_with_valid_affiliation(self):
+ xml_string = """
+
+
+
+
+
+ Costa
+ Ana
+
+
+
+
+
+ Universidade de São Paulo, SP, Brasil
+
+
+
+ """
+
+ article_xml = _create_xml_article(xml_string)
+ result = article_xml.get_contribs()
+
+ self.assertEqual(len(result["names"]), 1)
+ self.assertIn("Universidade de São Paulo", result["names"][0]["affiliation"])
+
+ def test_get_contribs_with_no_affs(self):
+ xml_string = """
+
+
+
+
+
+ Pereira
+ João
+
+
+
+
+
+ """
+
+ article_xml = _create_xml_article(xml_string)
+ result = article_xml.get_contribs()
+
+ self.assertEqual(len(result["names"]), 1)
+ self.assertEqual(result["names"][0]["surname"], "Pereira")
+ self.assertEqual(result["names"][0]["affiliation"], "")
+
+ def test_get_contribs_with_no_contribs(self):
+ xml_string = """
+
+
+
+
+ """
+
+ article_xml = _create_xml_article(xml_string)
+ result = article_xml.get_contribs()
+
+ self.assertEqual(result["names"], [])
+ self.assertEqual(result["collabs"], [])
diff --git a/requirements/base.txt b/requirements/base.txt
index dffe11f4e..8ca775e17 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -90,10 +90,10 @@ iso639-lang==2.6.3 # Mantendo versão maior
# SciELO Specific Packages
# ========================================
# Using specific versions for stability
--e git+https://github.com/scieloorg/packtools.git@4.14.4#egg=packtools
+-e git+https://github.com/scieloorg/packtools.git@4.16.1#egg=packtools
-e git+https://github.com/scieloorg/scielo_scholarly_data#egg=scielo_scholarly_data
-e git+https://github.com/scieloorg/opac_schema.git@v2.66#egg=opac_schema
--e git+https://github.com/scieloorg/scielo_migration.git@1.10.6#egg=scielo_classic_website
+-e git+https://github.com/scieloorg/scielo_migration.git@1.10.7#egg=scielo_classic_website
# ========================================
# Development & Testing
diff --git a/researcher/wagtail_hooks.py b/researcher/wagtail_hooks.py
index 2bddeb4a4..cfb796a1a 100644
--- a/researcher/wagtail_hooks.py
+++ b/researcher/wagtail_hooks.py
@@ -1,27 +1,18 @@
-from django.http import HttpResponseRedirect
from django.utils.translation import gettext_lazy as _
-from wagtail_modeladmin.options import ModelAdmin, modeladmin_register
-from wagtail_modeladmin.views import CreateView
+from wagtail.snippets.models import register_snippet
+from wagtail.snippets.views.snippets import SnippetViewSet
from config.menu import get_menu_order
from .models import Researcher
-class ResearcherCreateView(CreateView):
- def form_valid(self, form):
- self.object = form.save_all(self.request.user)
- return HttpResponseRedirect(self.get_success_url())
-
-
-class ResearcherAdmin(ModelAdmin):
+class ResearcherViewSet(SnippetViewSet):
model = Researcher
- create_view_class = ResearcherCreateView
menu_label = _("Researcher")
menu_icon = "folder"
menu_order = get_menu_order("researcher")
add_to_settings_menu = False
- exclude_from_explorer = False
-# modeladmin_register(ResearcherAdmin)
+register_snippet(ResearcherViewSet)
diff --git a/team/admin.py b/team/admin.py
index 8c38f3f3d..326c01178 100644
--- a/team/admin.py
+++ b/team/admin.py
@@ -1,3 +1,2 @@
-from django.contrib import admin
+# Team models are registered as Wagtail snippets in wagtail_hooks.py
-# Register your models here.
diff --git a/team/forms.py b/team/forms.py
deleted file mode 100644
index 42826dfe8..000000000
--- a/team/forms.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from wagtail.admin.forms import WagtailAdminModelForm
-
-
-class CollectionTeamMemberModelForm(WagtailAdminModelForm):
- def save_all(self, user):
- member = super().save(commit=False)
-
- if self.instance.pk is not None:
- member.updated_by = user
- else:
- member.creator = user
-
- self.save()
-
- return member
diff --git a/team/migrations/0002_company_companyteammember_journalcompanycontract_and_more.py b/team/migrations/0002_company_companyteammember_journalcompanycontract_and_more.py
new file mode 100644
index 000000000..3e461b38e
--- /dev/null
+++ b/team/migrations/0002_company_companyteammember_journalcompanycontract_and_more.py
@@ -0,0 +1,406 @@
+# Generated by Django 5.2.3 on 2026-02-18 23:03
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("journal", "0014_alter_journal_title_alter_officialjournal_title"),
+ ("team", "0001_initial"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Company",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "created",
+ models.DateTimeField(
+ auto_now_add=True, verbose_name="Creation date"
+ ),
+ ),
+ (
+ "updated",
+ models.DateTimeField(
+ auto_now=True, verbose_name="Last update date"
+ ),
+ ),
+ (
+ "name",
+ models.CharField(
+ max_length=255, unique=True, verbose_name="Company Name"
+ ),
+ ),
+ (
+ "description",
+ models.TextField(blank=True, null=True, verbose_name="Description"),
+ ),
+ (
+ "contact_email",
+ models.EmailField(
+ blank=True,
+ max_length=254,
+ null=True,
+ verbose_name="Contact Email",
+ ),
+ ),
+ (
+ "contact_phone",
+ models.CharField(
+ blank=True,
+ max_length=50,
+ null=True,
+ verbose_name="Contact Phone",
+ ),
+ ),
+ ("is_active", models.BooleanField(default=True, verbose_name="Active")),
+ (
+ "creator",
+ models.ForeignKey(
+ editable=False,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="%(class)s_creator",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Creator",
+ ),
+ ),
+ (
+ "updated_by",
+ models.ForeignKey(
+ blank=True,
+ editable=False,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="%(class)s_last_mod_user",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Updater",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Company",
+ "verbose_name_plural": "Companies",
+ },
+ ),
+ migrations.CreateModel(
+ name="CompanyTeamMember",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "created",
+ models.DateTimeField(
+ auto_now_add=True, verbose_name="Creation date"
+ ),
+ ),
+ (
+ "updated",
+ models.DateTimeField(
+ auto_now=True, verbose_name="Last update date"
+ ),
+ ),
+ (
+ "is_active_member",
+ models.BooleanField(blank=True, default=True, null=True),
+ ),
+ (
+ "role",
+ models.CharField(
+ choices=[("manager", "Manager"), ("member", "Member")],
+ default="member",
+ max_length=20,
+ verbose_name="Role",
+ ),
+ ),
+ (
+ "company",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="team_members",
+ to="team.company",
+ verbose_name="Company",
+ ),
+ ),
+ (
+ "creator",
+ models.ForeignKey(
+ editable=False,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="%(class)s_creator",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Creator",
+ ),
+ ),
+ (
+ "updated_by",
+ models.ForeignKey(
+ blank=True,
+ editable=False,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="%(class)s_last_mod_user",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Updater",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Company Team Member",
+ "verbose_name_plural": "Company Team Members",
+ },
+ ),
+ migrations.CreateModel(
+ name="JournalCompanyContract",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "created",
+ models.DateTimeField(
+ auto_now_add=True, verbose_name="Creation date"
+ ),
+ ),
+ (
+ "updated",
+ models.DateTimeField(
+ auto_now=True, verbose_name="Last update date"
+ ),
+ ),
+ ("is_active", models.BooleanField(default=True, verbose_name="Active")),
+ (
+ "start_date",
+ models.DateField(blank=True, null=True, verbose_name="Start Date"),
+ ),
+ (
+ "end_date",
+ models.DateField(blank=True, null=True, verbose_name="End Date"),
+ ),
+ (
+ "notes",
+ models.TextField(blank=True, null=True, verbose_name="Notes"),
+ ),
+ (
+ "company",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="journal_contracts",
+ to="team.company",
+ verbose_name="Company",
+ ),
+ ),
+ (
+ "creator",
+ models.ForeignKey(
+ editable=False,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="%(class)s_creator",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Creator",
+ ),
+ ),
+ (
+ "journal",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="company_contracts",
+ to="journal.journal",
+ verbose_name="Journal",
+ ),
+ ),
+ (
+ "updated_by",
+ models.ForeignKey(
+ blank=True,
+ editable=False,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="%(class)s_last_mod_user",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Updater",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Journal-Company Contract",
+ "verbose_name_plural": "Journal-Company Contracts",
+ },
+ ),
+ migrations.CreateModel(
+ name="JournalTeamMember",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "created",
+ models.DateTimeField(
+ auto_now_add=True, verbose_name="Creation date"
+ ),
+ ),
+ (
+ "updated",
+ models.DateTimeField(
+ auto_now=True, verbose_name="Last update date"
+ ),
+ ),
+ (
+ "is_active_member",
+ models.BooleanField(blank=True, default=True, null=True),
+ ),
+ (
+ "role",
+ models.CharField(
+ choices=[("manager", "Manager"), ("member", "Member")],
+ default="member",
+ max_length=20,
+ verbose_name="Role",
+ ),
+ ),
+ (
+ "creator",
+ models.ForeignKey(
+ editable=False,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="%(class)s_creator",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Creator",
+ ),
+ ),
+ (
+ "journal",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="team_members",
+ to="journal.journal",
+ verbose_name="Journal",
+ ),
+ ),
+ (
+ "updated_by",
+ models.ForeignKey(
+ blank=True,
+ editable=False,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="%(class)s_last_mod_user",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Updater",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Journal Team Member",
+ "verbose_name_plural": "Journal Team Members",
+ },
+ ),
+ migrations.AddIndex(
+ model_name="company",
+ index=models.Index(fields=["name"], name="team_compan_name_fce1e5_idx"),
+ ),
+ migrations.AddIndex(
+ model_name="company",
+ index=models.Index(
+ fields=["is_active"], name="team_compan_is_acti_66037b_idx"
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="companyteammember",
+ index=models.Index(
+ fields=["company", "role"], name="team_compan_company_9915d9_idx"
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="companyteammember",
+ index=models.Index(
+ fields=["user", "is_active_member"],
+ name="team_compan_user_id_cecff1_idx",
+ ),
+ ),
+ migrations.AlterUniqueTogether(
+ name="companyteammember",
+ unique_together={("user", "company")},
+ ),
+ migrations.AddIndex(
+ model_name="journalcompanycontract",
+ index=models.Index(
+ fields=["journal", "is_active"], name="team_journa_journal_1acfa7_idx"
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="journalcompanycontract",
+ index=models.Index(
+ fields=["company", "is_active"], name="team_journa_company_d18904_idx"
+ ),
+ ),
+ migrations.AlterUniqueTogether(
+ name="journalcompanycontract",
+ unique_together={("journal", "company")},
+ ),
+ migrations.AddIndex(
+ model_name="journalteammember",
+ index=models.Index(
+ fields=["journal", "role"], name="team_journa_journal_d9a41d_idx"
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="journalteammember",
+ index=models.Index(
+ fields=["user", "is_active_member"],
+ name="team_journa_user_id_dfe846_idx",
+ ),
+ ),
+ migrations.AlterUniqueTogether(
+ name="journalteammember",
+ unique_together={("user", "journal")},
+ ),
+ ]
diff --git a/team/migrations/0003_add_role_to_collectionteammember.py b/team/migrations/0003_add_role_to_collectionteammember.py
new file mode 100644
index 000000000..81a3c898b
--- /dev/null
+++ b/team/migrations/0003_add_role_to_collectionteammember.py
@@ -0,0 +1,36 @@
+# Generated by Django 5.2.3 on 2026-02-19 01:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("team", "0002_company_companyteammember_journalcompanycontract_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="collectionteammember",
+ name="role",
+ field=models.CharField(
+ choices=[("manager", "Manager"), ("member", "Member")],
+ default="member",
+ max_length=20,
+ verbose_name="Role",
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="collectionteammember",
+ index=models.Index(
+ fields=["collection", "role"],
+ name="team_collec_collect_idx",
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="collectionteammember",
+ index=models.Index(
+ fields=["user", "is_active_member"],
+ name="team_collec_user_id_idx",
+ ),
+ ),
+ ]
diff --git a/team/migrations/0004_rename_team_collec_collect_idx_team_collec_collect_0ee96e_idx_and_more.py b/team/migrations/0004_rename_team_collec_collect_idx_team_collec_collect_0ee96e_idx_and_more.py
new file mode 100644
index 000000000..1f47e1ce9
--- /dev/null
+++ b/team/migrations/0004_rename_team_collec_collect_idx_team_collec_collect_0ee96e_idx_and_more.py
@@ -0,0 +1,48 @@
+# Generated by Django 5.2.3 on 2026-02-19 18:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("team", "0003_add_role_to_collectionteammember"),
+ ]
+
+ operations = [
+ migrations.RenameIndex(
+ model_name="collectionteammember",
+ new_name="team_collec_collect_0ee96e_idx",
+ old_name="team_collec_collect_idx",
+ ),
+ migrations.RenameIndex(
+ model_name="collectionteammember",
+ new_name="team_collec_user_id_6382ef_idx",
+ old_name="team_collec_user_id_idx",
+ ),
+ migrations.AddField(
+ model_name="company",
+ name="certified_since",
+ field=models.DateField(
+ blank=True, null=True, verbose_name="Certified Since"
+ ),
+ ),
+ migrations.AddField(
+ model_name="company",
+ name="logo",
+ field=models.ImageField(
+ blank=True, null=True, upload_to="logos/", verbose_name="Logo"
+ ),
+ ),
+ migrations.AddField(
+ model_name="company",
+ name="personal_contact",
+ field=models.CharField(
+ blank=True, max_length=30, null=True, verbose_name="Personal Contact"
+ ),
+ ),
+ migrations.AddField(
+ model_name="company",
+ name="url",
+ field=models.URLField(blank=True, null=True, verbose_name="URL"),
+ ),
+ ]
diff --git a/team/models.py b/team/models.py
index 562ce9e2a..52d0fe7a4 100644
--- a/team/models.py
+++ b/team/models.py
@@ -4,30 +4,82 @@
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
-from modelcluster.fields import ParentalKey
-from modelcluster.models import ClusterableModel
-from wagtail.admin.panels import FieldPanel, InlinePanel, ObjectList, TabbedInterface
+from wagtail.admin.panels import FieldPanel
from wagtailautocomplete.edit_handlers import AutocompletePanel
from collection.models import Collection
+from core.models import CommonControlField, VisualIdentityMixin
from core.forms import CoreAdminModelForm
-from core.models import CommonControlField
-from team.forms import CollectionTeamMemberModelForm
-
User = get_user_model()
ALLOWED_COLLECTIONS = ["dom", "spa", "scl", "pan"]
+class TeamRole(models.TextChoices):
+ """Role types for team members."""
+ MANAGER = "manager", _("Manager")
+ MEMBER = "member", _("Member")
+
+
+def get_user_membership_ids(user):
+ """
+ Returns a dict with the list IDs of collections, journals or companies
+ that the user is actively associated with, depending on team membership type.
+ Priority order: collection > journal > company.
+
+ For collection team members, journal_list_ids is also populated with the journals
+ that belong to the user's collections.
+ For company team members, journal_list_ids is also populated with the journals
+ that have active contracts with the user's companies.
+ """
+ from journal.models import JournalCollection
+
+ result = {"collection_list_ids": [], "journal_list_ids": [], "company_list_ids": []}
+
+ collection_ids = list(
+ CollectionTeamMember.objects.filter(user=user, is_active_member=True)
+ .values_list("collection", flat=True)
+ )
+ if collection_ids:
+ result["collection_list_ids"] = collection_ids
+ result["journal_list_ids"] = list(
+ JournalCollection.objects.filter(
+ collection__in=collection_ids
+ ).values_list("journal", flat=True)
+ )
+ return result
+
+ journal_ids = list(
+ JournalTeamMember.objects.filter(user=user, is_active_member=True)
+ .values_list("journal", flat=True)
+ )
+ if journal_ids:
+ result["journal_list_ids"] = journal_ids
+ return result
+
+ company_ids = list(
+ CompanyTeamMember.objects.filter(user=user, is_active_member=True)
+ .values_list("company", flat=True)
+ )
+ if company_ids:
+ result["company_list_ids"] = company_ids
+ result["journal_list_ids"] = list(
+ JournalCompanyContract.objects.filter(
+ company__in=company_ids, is_active=True
+ ).values_list("journal", flat=True)
+ )
+ return result
+
+
def has_permission(user=None):
try:
if not user:
- logging.info(f"has_permission: collection")
+ logging.info("has_permission: collection")
return Collection.objects.filter(acron__in=ALLOWED_COLLECTIONS).exists()
- logging.info(f"has_permission: user")
+ logging.info("has_permission: user")
return CollectionTeamMember.has_upload_permission(user)
- except Exception as e:
+ except Exception:
return False
@@ -35,6 +87,7 @@ class TeamMember(CommonControlField):
user = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL)
is_active_member = models.BooleanField(null=True, blank=True, default=True)
+ base_form_class = CoreAdminModelForm
panels = [
FieldPanel("user"),
FieldPanel("is_active_member"),
@@ -68,11 +121,17 @@ class CollectionTeamMember(TeamMember):
collection = models.ForeignKey(
Collection, null=True, blank=True, on_delete=models.SET_NULL
)
+ role = models.CharField(
+ _("Role"),
+ max_length=20,
+ choices=TeamRole.choices,
+ default=TeamRole.MEMBER
+ )
- base_form_class = CollectionTeamMemberModelForm
panels = [
AutocompletePanel("collection"),
AutocompletePanel("user"),
+ FieldPanel("role"),
FieldPanel("is_active_member"),
]
@@ -80,6 +139,10 @@ class Meta:
verbose_name = _("Team member")
verbose_name_plural = _("Team members")
unique_together = ("user", "collection")
+ indexes = [
+ models.Index(fields=["collection", "role"]),
+ models.Index(fields=["user", "is_active_member"]),
+ ]
@staticmethod
def autocomplete_custom_queryset_filter(text):
@@ -90,10 +153,34 @@ def autocomplete_custom_queryset_filter(text):
)
def autocomplete_label(self):
- return str(self.user)
+ return f"{self.user} - {self.collection} ({self.get_role_display()})"
def __str__(self):
- return f"{self.user} ({self.collection})"
+ return f"{self.user} - {self.collection} ({self.get_role_display()})"
+
+ def is_manager(self):
+ """Check if this member is a manager."""
+ return self.role == TeamRole.MANAGER
+
+ @classmethod
+ def user_is_manager(cls, user, collection):
+ """Check if a user is a manager for a specific collection."""
+ return cls.objects.filter(
+ user=user,
+ collection=collection,
+ role=TeamRole.MANAGER,
+ is_active_member=True
+ ).exists()
+
+ @classmethod
+ def get_user_collections(cls, user, role=None, is_active=True):
+ """Get all collections a user is associated with."""
+ filters = {"user": user}
+ if role:
+ filters["role"] = role
+ if is_active is not None:
+ filters["is_active_member"] = is_active
+ return cls.objects.filter(**filters).select_related("collection")
@staticmethod
def collections(user, is_active_member=None):
@@ -106,7 +193,7 @@ def collections(user, is_active_member=None):
else:
for member in CollectionTeamMember.objects.filter(user=user):
yield member.collection
- except Exception as e:
+ except Exception:
return Collection.objects.all()
@staticmethod
@@ -117,3 +204,276 @@ def members(user, is_active_member=None):
@classmethod
def has_upload_permission(cls, user):
return cls.objects.filter(user=user, collection__acron__in=ALLOWED_COLLECTIONS).exists()
+
+
+class Company(VisualIdentityMixin, CommonControlField):
+ """
+ Company represents an editorial services provider that can be contracted
+ by journals to produce XML files.
+ """
+ name = models.CharField(_("Company Name"), max_length=255, unique=True)
+ description = models.TextField(_("Description"), blank=True, null=True)
+ personal_contact = models.CharField(_("Personal Contact"), max_length=30, blank=True, null=True)
+ contact_email = models.EmailField(_("Contact Email"), blank=True, null=True)
+ contact_phone = models.CharField(_("Contact Phone"), max_length=50, blank=True, null=True)
+ certified_since = models.DateField(_("Certified Since"), blank=True, null=True)
+ is_active = models.BooleanField(_("Active"), default=True)
+
+ class Meta:
+ verbose_name = _("Company")
+ verbose_name_plural = _("Companies")
+ indexes = [
+ models.Index(fields=["name"]),
+ models.Index(fields=["is_active"]),
+ ]
+ base_form_class = CoreAdminModelForm
+ panels = [
+ FieldPanel("name"),
+ FieldPanel("description"),
+ FieldPanel("url"),
+ FieldPanel("logo"),
+ FieldPanel("personal_contact"),
+ FieldPanel("contact_email"),
+ FieldPanel("contact_phone"),
+ FieldPanel("certified_since"),
+ FieldPanel("is_active"),
+ ]
+
+ def __str__(self):
+ return self.name
+
+ autocomplete_search_field = "name"
+
+ def autocomplete_label(self):
+ return self.name
+
+ @classmethod
+ def get_managers(cls, company_id):
+ """Get all managers for this company."""
+ return CompanyTeamMember.objects.filter(
+ company_id=company_id,
+ role=TeamRole.MANAGER,
+ is_active_member=True
+ )
+
+ @classmethod
+ def get_members(cls, company_id):
+ """Get all active members (including managers) for this company."""
+ return CompanyTeamMember.objects.filter(
+ company_id=company_id,
+ is_active_member=True
+ )
+
+
+class JournalTeamMember(TeamMember):
+ """
+ Editorial team members associated with a specific journal.
+ Can be either managers or regular members.
+ """
+ journal = models.ForeignKey(
+ "journal.Journal",
+ on_delete=models.CASCADE,
+ related_name="team_members",
+ verbose_name=_("Journal")
+ )
+ role = models.CharField(
+ _("Role"),
+ max_length=20,
+ choices=TeamRole.choices,
+ default=TeamRole.MEMBER
+ )
+
+ class Meta:
+ verbose_name = _("Journal Team Member")
+ verbose_name_plural = _("Journal Team Members")
+ unique_together = ("user", "journal")
+ indexes = [
+ models.Index(fields=["journal", "role"]),
+ models.Index(fields=["user", "is_active_member"]),
+ ]
+
+ panels = [
+ AutocompletePanel("journal"),
+ AutocompletePanel("user"),
+ FieldPanel("role"),
+ FieldPanel("is_active_member"),
+ ]
+
+ def __str__(self):
+ return f"{self.user} - {self.journal} ({self.get_role_display()})"
+
+ @staticmethod
+ def autocomplete_custom_queryset_filter(text):
+ return JournalTeamMember.objects.filter(
+ Q(user__username__icontains=text)
+ | Q(user__email__icontains=text)
+ | Q(user__name__icontains=text)
+ | Q(journal__title__icontains=text)
+ )
+
+ def autocomplete_label(self):
+ return f"{self.user} - {self.journal} ({self.get_role_display()})"
+
+ def is_manager(self):
+ """Check if this member is a manager."""
+ return self.role == TeamRole.MANAGER
+
+ @classmethod
+ def user_is_manager(cls, user, journal):
+ """Check if a user is a manager for a specific journal."""
+ return cls.objects.filter(
+ user=user,
+ journal=journal,
+ role=TeamRole.MANAGER,
+ is_active_member=True
+ ).exists()
+
+ @classmethod
+ def get_user_journals(cls, user, role=None, is_active=True):
+ """Get all journals a user is associated with."""
+ filters = {"user": user}
+ if role:
+ filters["role"] = role
+ if is_active is not None:
+ filters["is_active_member"] = is_active
+ return cls.objects.filter(**filters).select_related("journal")
+
+
+class CompanyTeamMember(TeamMember):
+ """
+ Company team members (XML providers) associated with an editorial services company.
+ Can be either managers or regular members.
+ """
+ company = models.ForeignKey(
+ Company,
+ on_delete=models.CASCADE,
+ related_name="team_members",
+ verbose_name=_("Company")
+ )
+ role = models.CharField(
+ _("Role"),
+ max_length=20,
+ choices=TeamRole.choices,
+ default=TeamRole.MEMBER
+ )
+
+ class Meta:
+ verbose_name = _("Company Team Member")
+ verbose_name_plural = _("Company Team Members")
+ unique_together = ("user", "company")
+ indexes = [
+ models.Index(fields=["company", "role"]),
+ models.Index(fields=["user", "is_active_member"]),
+ ]
+
+ panels = [
+ AutocompletePanel("company"),
+ AutocompletePanel("user"),
+ FieldPanel("role"),
+ FieldPanel("is_active_member"),
+ ]
+
+ def __str__(self):
+ return f"{self.user} - {self.company} ({self.get_role_display()})"
+
+ @staticmethod
+ def autocomplete_custom_queryset_filter(text):
+ return CompanyTeamMember.objects.filter(
+ Q(user__username__icontains=text)
+ | Q(user__email__icontains=text)
+ | Q(user__name__icontains=text)
+ | Q(company__name__icontains=text)
+ )
+
+ def autocomplete_label(self):
+ return f"{self.user} - {self.company} ({self.get_role_display()})"
+
+ def is_manager(self):
+ """Check if this member is a manager."""
+ return self.role == TeamRole.MANAGER
+
+ @classmethod
+ def user_is_manager(cls, user, company):
+ """Check if a user is a manager for a specific company."""
+ return cls.objects.filter(
+ user=user,
+ company=company,
+ role=TeamRole.MANAGER,
+ is_active_member=True
+ ).exists()
+
+ @classmethod
+ def get_user_companies(cls, user, role=None, is_active=True):
+ """Get all companies a user is associated with."""
+ filters = {"user": user}
+ if role:
+ filters["role"] = role
+ if is_active is not None:
+ filters["is_active_member"] = is_active
+ return cls.objects.filter(**filters).select_related("company")
+
+
+class JournalCompanyContract(CommonControlField):
+ """
+ Represents a contract between a journal and a company for XML production services.
+ Journal managers can manage these contracts.
+ """
+ journal = models.ForeignKey(
+ "journal.Journal",
+ on_delete=models.CASCADE,
+ related_name="company_contracts",
+ verbose_name=_("Journal")
+ )
+ company = models.ForeignKey(
+ Company,
+ on_delete=models.CASCADE,
+ related_name="journal_contracts",
+ verbose_name=_("Company")
+ )
+ is_active = models.BooleanField(_("Active"), default=True)
+ start_date = models.DateField(_("Start Date"), null=True, blank=True)
+ end_date = models.DateField(_("End Date"), null=True, blank=True)
+ notes = models.TextField(_("Notes"), blank=True, null=True)
+
+ class Meta:
+ verbose_name = _("Journal-Company Contract")
+ verbose_name_plural = _("Journal-Company Contracts")
+ unique_together = ("journal", "company")
+ indexes = [
+ models.Index(fields=["journal", "is_active"]),
+ models.Index(fields=["company", "is_active"]),
+ ]
+ base_form_class = CoreAdminModelForm
+ panels = [
+ AutocompletePanel("journal"),
+ AutocompletePanel("company"),
+ FieldPanel("is_active"),
+ FieldPanel("start_date"),
+ FieldPanel("end_date"),
+ FieldPanel("notes"),
+ ]
+
+ def __str__(self):
+ status = "Active" if self.is_active else "Inactive"
+ return f"{self.journal} - {self.company} ({status})"
+
+ @classmethod
+ def get_journal_companies(cls, journal, is_active=True):
+ """Get all companies contracted by a journal."""
+ filters = {"journal": journal}
+ if is_active is not None:
+ filters["is_active"] = is_active
+ return cls.objects.filter(**filters).select_related("company")
+
+ @classmethod
+ def get_company_journals(cls, company, is_active=True):
+ """Get all journals that contracted a company."""
+ filters = {"company": company}
+ if is_active is not None:
+ filters["is_active"] = is_active
+ return cls.objects.filter(**filters).select_related("journal")
+
+ @classmethod
+ def can_manage_contract(cls, user, journal):
+ """Check if a user can manage contracts for a journal (must be a journal manager)."""
+ return JournalTeamMember.user_is_manager(user, journal)
diff --git a/team/tests.py b/team/tests.py
index 7ce503c2d..0d46b39ef 100644
--- a/team/tests.py
+++ b/team/tests.py
@@ -1,3 +1,542 @@
+from django.contrib.auth import get_user_model
+from django.db import IntegrityError
from django.test import TestCase
-# Create your tests here.
+from collection.models import Collection
+from journal.models import Journal
+from team.models import (
+ CollectionTeamMember,
+ Company,
+ CompanyTeamMember,
+ JournalCompanyContract,
+ JournalTeamMember,
+ TeamRole,
+)
+
+User = get_user_model()
+
+
+class CollectionTeamMemberModelTest(TestCase):
+ """Test cases for the CollectionTeamMember model."""
+
+ def setUp(self):
+ self.user = User.objects.create_user(
+ username="testuser", email="test@example.com", password="testpass123"
+ )
+ self.manager_user = User.objects.create_user(
+ username="manager", email="manager@example.com", password="testpass123"
+ )
+ self.collection = Collection.objects.create(
+ acron="TST",
+ name="Test Collection",
+ creator=self.user,
+ )
+
+ def test_create_collection_team_member(self):
+ """Test creating a collection team member."""
+ member = CollectionTeamMember.objects.create(
+ user=self.user,
+ collection=self.collection,
+ role=TeamRole.MEMBER,
+ is_active_member=True,
+ creator=self.user,
+ )
+ self.assertEqual(member.user, self.user)
+ self.assertEqual(member.collection, self.collection)
+ self.assertEqual(member.role, TeamRole.MEMBER)
+ self.assertFalse(member.is_manager())
+
+ def test_create_collection_team_manager(self):
+ """Test creating a collection team manager."""
+ manager = CollectionTeamMember.objects.create(
+ user=self.manager_user,
+ collection=self.collection,
+ role=TeamRole.MANAGER,
+ is_active_member=True,
+ creator=self.user,
+ )
+ self.assertEqual(manager.role, TeamRole.MANAGER)
+ self.assertTrue(manager.is_manager())
+
+ def test_collection_team_member_unique_together(self):
+ """Test that a user can only be added once to a collection."""
+ CollectionTeamMember.objects.create(
+ user=self.user,
+ collection=self.collection,
+ role=TeamRole.MEMBER,
+ creator=self.user,
+ )
+ with self.assertRaises(IntegrityError):
+ CollectionTeamMember.objects.create(
+ user=self.user,
+ collection=self.collection,
+ role=TeamRole.MANAGER,
+ creator=self.user,
+ )
+
+ def test_user_is_manager(self):
+ """Test checking if a user is a collection manager."""
+ CollectionTeamMember.objects.create(
+ user=self.manager_user,
+ collection=self.collection,
+ role=TeamRole.MANAGER,
+ is_active_member=True,
+ creator=self.user,
+ )
+ self.assertTrue(
+ CollectionTeamMember.user_is_manager(self.manager_user, self.collection)
+ )
+ self.assertFalse(CollectionTeamMember.user_is_manager(self.user, self.collection))
+
+ def test_get_user_collections(self):
+ """Test getting collections for a user."""
+ CollectionTeamMember.objects.create(
+ user=self.user,
+ collection=self.collection,
+ role=TeamRole.MEMBER,
+ is_active_member=True,
+ creator=self.user,
+ )
+ collections = CollectionTeamMember.get_user_collections(self.user)
+ self.assertEqual(collections.count(), 1)
+ self.assertEqual(collections.first().collection, self.collection)
+
+ def test_collection_get_managers(self):
+ """Test getting managers for a collection."""
+ CollectionTeamMember.objects.create(
+ user=self.manager_user,
+ collection=self.collection,
+ role=TeamRole.MANAGER,
+ is_active_member=True,
+ creator=self.user,
+ )
+ CollectionTeamMember.objects.create(
+ user=self.user,
+ collection=self.collection,
+ role=TeamRole.MEMBER,
+ is_active_member=True,
+ creator=self.user,
+ )
+ managers = Collection.get_managers(self.collection.id)
+ self.assertEqual(managers.count(), 1)
+ self.assertEqual(managers.first().user, self.manager_user)
+
+ def test_collection_get_members(self):
+ """Test getting all members (including managers) for a collection."""
+ CollectionTeamMember.objects.create(
+ user=self.manager_user,
+ collection=self.collection,
+ role=TeamRole.MANAGER,
+ is_active_member=True,
+ creator=self.user,
+ )
+ CollectionTeamMember.objects.create(
+ user=self.user,
+ collection=self.collection,
+ role=TeamRole.MEMBER,
+ is_active_member=True,
+ creator=self.user,
+ )
+ members = Collection.get_members(self.collection.id)
+ self.assertEqual(members.count(), 2)
+
+ def test_default_role_is_member(self):
+ """Test that the default role is MEMBER."""
+ member = CollectionTeamMember.objects.create(
+ user=self.user,
+ collection=self.collection,
+ is_active_member=True,
+ creator=self.user,
+ )
+ self.assertEqual(member.role, TeamRole.MEMBER)
+
+ def test_autocomplete_label_includes_role(self):
+ """Test that autocomplete label includes role."""
+ member = CollectionTeamMember.objects.create(
+ user=self.user,
+ collection=self.collection,
+ role=TeamRole.MEMBER,
+ creator=self.user,
+ )
+ label = member.autocomplete_label()
+ self.assertIn("Member", label)
+ self.assertIn(str(self.user), label)
+ self.assertIn(str(self.collection), label)
+
+ def test_str_includes_role(self):
+ """Test that string representation includes role."""
+ manager = CollectionTeamMember.objects.create(
+ user=self.manager_user,
+ collection=self.collection,
+ role=TeamRole.MANAGER,
+ creator=self.user,
+ )
+ str_repr = str(manager)
+ self.assertIn("Manager", str_repr)
+ self.assertIn(str(self.manager_user), str_repr)
+
+
+class CompanyModelTest(TestCase):
+ """Test cases for the Company model."""
+
+ def setUp(self):
+ self.user = User.objects.create_user(
+ username="testuser", email="test@example.com", password="testpass123"
+ )
+
+ def test_create_company(self):
+ """Test creating a company."""
+ company = Company.objects.create(
+ name="Test Company",
+ description="A test company",
+ contact_email="contact@testcompany.com",
+ contact_phone="+55 11 1234-5678",
+ is_active=True,
+ creator=self.user,
+ )
+ self.assertEqual(company.name, "Test Company")
+ self.assertTrue(company.is_active)
+ self.assertEqual(str(company), "Test Company")
+
+ def test_company_unique_name(self):
+ """Test that company names must be unique."""
+ Company.objects.create(
+ name="Unique Company",
+ creator=self.user,
+ )
+ with self.assertRaises(IntegrityError):
+ Company.objects.create(
+ name="Unique Company",
+ creator=self.user,
+ )
+
+ def test_company_autocomplete_label(self):
+ """Test company autocomplete label."""
+ company = Company.objects.create(
+ name="Test Company",
+ creator=self.user,
+ )
+ self.assertEqual(company.autocomplete_label(), "Test Company")
+
+ def test_company_with_visual_identity(self):
+ """Test creating a company with url, logo, certified_since, and personal_contact."""
+ from datetime import date
+ company = Company.objects.create(
+ name="Certified Company",
+ url="https://example.com",
+ personal_contact="John Doe",
+ certified_since=date(2020, 1, 1),
+ creator=self.user,
+ )
+ self.assertEqual(company.url, "https://example.com")
+ self.assertEqual(company.personal_contact, "John Doe")
+ self.assertEqual(company.certified_since, date(2020, 1, 1))
+
+
+class JournalTeamMemberModelTest(TestCase):
+ """Test cases for the JournalTeamMember model."""
+
+ def setUp(self):
+ self.user = User.objects.create_user(
+ username="testuser", email="test@example.com", password="testpass123"
+ )
+ self.manager_user = User.objects.create_user(
+ username="manager", email="manager@example.com", password="testpass123"
+ )
+ # Create a minimal journal for testing
+ self.journal = Journal.objects.create(
+ title="Test Journal",
+ creator=self.user,
+ )
+
+ def test_create_journal_team_member(self):
+ """Test creating a journal team member."""
+ member = JournalTeamMember.objects.create(
+ user=self.user,
+ journal=self.journal,
+ role=TeamRole.MEMBER,
+ is_active_member=True,
+ creator=self.user,
+ )
+ self.assertEqual(member.user, self.user)
+ self.assertEqual(member.journal, self.journal)
+ self.assertEqual(member.role, TeamRole.MEMBER)
+ self.assertFalse(member.is_manager())
+
+ def test_create_journal_team_manager(self):
+ """Test creating a journal team manager."""
+ manager = JournalTeamMember.objects.create(
+ user=self.manager_user,
+ journal=self.journal,
+ role=TeamRole.MANAGER,
+ is_active_member=True,
+ creator=self.user,
+ )
+ self.assertEqual(manager.role, TeamRole.MANAGER)
+ self.assertTrue(manager.is_manager())
+
+ def test_journal_team_member_unique_together(self):
+ """Test that a user can only be added once to a journal."""
+ JournalTeamMember.objects.create(
+ user=self.user,
+ journal=self.journal,
+ role=TeamRole.MEMBER,
+ creator=self.user,
+ )
+ with self.assertRaises(IntegrityError):
+ JournalTeamMember.objects.create(
+ user=self.user,
+ journal=self.journal,
+ role=TeamRole.MANAGER,
+ creator=self.user,
+ )
+
+ def test_user_is_manager(self):
+ """Test checking if a user is a journal manager."""
+ JournalTeamMember.objects.create(
+ user=self.manager_user,
+ journal=self.journal,
+ role=TeamRole.MANAGER,
+ is_active_member=True,
+ creator=self.user,
+ )
+ self.assertTrue(
+ JournalTeamMember.user_is_manager(self.manager_user, self.journal)
+ )
+ self.assertFalse(JournalTeamMember.user_is_manager(self.user, self.journal))
+
+ def test_get_user_journals(self):
+ """Test getting journals for a user."""
+ JournalTeamMember.objects.create(
+ user=self.user,
+ journal=self.journal,
+ role=TeamRole.MEMBER,
+ is_active_member=True,
+ creator=self.user,
+ )
+ journals = JournalTeamMember.get_user_journals(self.user)
+ self.assertEqual(journals.count(), 1)
+ self.assertEqual(journals.first().journal, self.journal)
+
+
+class CompanyTeamMemberModelTest(TestCase):
+ """Test cases for the CompanyTeamMember model."""
+
+ def setUp(self):
+ self.user = User.objects.create_user(
+ username="testuser", email="test@example.com", password="testpass123"
+ )
+ self.manager_user = User.objects.create_user(
+ username="manager", email="manager@example.com", password="testpass123"
+ )
+ self.company = Company.objects.create(
+ name="Test Company",
+ creator=self.user,
+ )
+
+ def test_create_company_team_member(self):
+ """Test creating a company team member."""
+ member = CompanyTeamMember.objects.create(
+ user=self.user,
+ company=self.company,
+ role=TeamRole.MEMBER,
+ is_active_member=True,
+ creator=self.user,
+ )
+ self.assertEqual(member.user, self.user)
+ self.assertEqual(member.company, self.company)
+ self.assertEqual(member.role, TeamRole.MEMBER)
+ self.assertFalse(member.is_manager())
+
+ def test_create_company_team_manager(self):
+ """Test creating a company team manager."""
+ manager = CompanyTeamMember.objects.create(
+ user=self.manager_user,
+ company=self.company,
+ role=TeamRole.MANAGER,
+ is_active_member=True,
+ creator=self.user,
+ )
+ self.assertEqual(manager.role, TeamRole.MANAGER)
+ self.assertTrue(manager.is_manager())
+
+ def test_company_team_member_unique_together(self):
+ """Test that a user can only be added once to a company."""
+ CompanyTeamMember.objects.create(
+ user=self.user,
+ company=self.company,
+ role=TeamRole.MEMBER,
+ creator=self.user,
+ )
+ with self.assertRaises(IntegrityError):
+ CompanyTeamMember.objects.create(
+ user=self.user,
+ company=self.company,
+ role=TeamRole.MANAGER,
+ creator=self.user,
+ )
+
+ def test_user_is_manager(self):
+ """Test checking if a user is a company manager."""
+ CompanyTeamMember.objects.create(
+ user=self.manager_user,
+ company=self.company,
+ role=TeamRole.MANAGER,
+ is_active_member=True,
+ creator=self.user,
+ )
+ self.assertTrue(
+ CompanyTeamMember.user_is_manager(self.manager_user, self.company)
+ )
+ self.assertFalse(CompanyTeamMember.user_is_manager(self.user, self.company))
+
+ def test_get_user_companies(self):
+ """Test getting companies for a user."""
+ CompanyTeamMember.objects.create(
+ user=self.user,
+ company=self.company,
+ role=TeamRole.MEMBER,
+ is_active_member=True,
+ creator=self.user,
+ )
+ companies = CompanyTeamMember.get_user_companies(self.user)
+ self.assertEqual(companies.count(), 1)
+ self.assertEqual(companies.first().company, self.company)
+
+ def test_company_get_managers(self):
+ """Test getting managers for a company."""
+ CompanyTeamMember.objects.create(
+ user=self.manager_user,
+ company=self.company,
+ role=TeamRole.MANAGER,
+ is_active_member=True,
+ creator=self.user,
+ )
+ CompanyTeamMember.objects.create(
+ user=self.user,
+ company=self.company,
+ role=TeamRole.MEMBER,
+ is_active_member=True,
+ creator=self.user,
+ )
+ managers = Company.get_managers(self.company.id)
+ self.assertEqual(managers.count(), 1)
+ self.assertEqual(managers.first().user, self.manager_user)
+
+ def test_company_get_members(self):
+ """Test getting all members (including managers) for a company."""
+ CompanyTeamMember.objects.create(
+ user=self.manager_user,
+ company=self.company,
+ role=TeamRole.MANAGER,
+ is_active_member=True,
+ creator=self.user,
+ )
+ CompanyTeamMember.objects.create(
+ user=self.user,
+ company=self.company,
+ role=TeamRole.MEMBER,
+ is_active_member=True,
+ creator=self.user,
+ )
+ members = Company.get_members(self.company.id)
+ self.assertEqual(members.count(), 2)
+
+
+class JournalCompanyContractModelTest(TestCase):
+ """Test cases for the JournalCompanyContract model."""
+
+ def setUp(self):
+ self.user = User.objects.create_user(
+ username="testuser", email="test@example.com", password="testpass123"
+ )
+ self.manager_user = User.objects.create_user(
+ username="manager", email="manager@example.com", password="testpass123"
+ )
+ self.journal = Journal.objects.create(
+ title="Test Journal",
+ creator=self.user,
+ )
+ self.company = Company.objects.create(
+ name="Test Company",
+ creator=self.user,
+ )
+
+ def test_create_contract(self):
+ """Test creating a journal-company contract."""
+ contract = JournalCompanyContract.objects.create(
+ journal=self.journal,
+ company=self.company,
+ is_active=True,
+ notes="Test contract",
+ creator=self.user,
+ )
+ self.assertEqual(contract.journal, self.journal)
+ self.assertEqual(contract.company, self.company)
+ self.assertTrue(contract.is_active)
+ self.assertIn(str(self.journal), str(contract))
+ self.assertIn(str(self.company), str(contract))
+
+ def test_contract_unique_together(self):
+ """Test that a journal-company pair must be unique."""
+ JournalCompanyContract.objects.create(
+ journal=self.journal,
+ company=self.company,
+ creator=self.user,
+ )
+ with self.assertRaises(IntegrityError):
+ JournalCompanyContract.objects.create(
+ journal=self.journal,
+ company=self.company,
+ creator=self.user,
+ )
+
+ def test_get_journal_companies(self):
+ """Test getting companies contracted by a journal."""
+ JournalCompanyContract.objects.create(
+ journal=self.journal,
+ company=self.company,
+ is_active=True,
+ creator=self.user,
+ )
+ contracts = JournalCompanyContract.get_journal_companies(self.journal)
+ self.assertEqual(contracts.count(), 1)
+ self.assertEqual(contracts.first().company, self.company)
+
+ def test_get_company_journals(self):
+ """Test getting journals that contracted a company."""
+ JournalCompanyContract.objects.create(
+ journal=self.journal,
+ company=self.company,
+ is_active=True,
+ creator=self.user,
+ )
+ contracts = JournalCompanyContract.get_company_journals(self.company)
+ self.assertEqual(contracts.count(), 1)
+ self.assertEqual(contracts.first().journal, self.journal)
+
+ def test_can_manage_contract_as_manager(self):
+ """Test that journal managers can manage contracts."""
+ JournalTeamMember.objects.create(
+ user=self.manager_user,
+ journal=self.journal,
+ role=TeamRole.MANAGER,
+ is_active_member=True,
+ creator=self.user,
+ )
+ self.assertTrue(
+ JournalCompanyContract.can_manage_contract(self.manager_user, self.journal)
+ )
+
+ def test_can_manage_contract_as_non_manager(self):
+ """Test that non-managers cannot manage contracts."""
+ JournalTeamMember.objects.create(
+ user=self.user,
+ journal=self.journal,
+ role=TeamRole.MEMBER,
+ is_active_member=True,
+ creator=self.user,
+ )
+ self.assertFalse(
+ JournalCompanyContract.can_manage_contract(self.user, self.journal)
+ )
diff --git a/team/views.py b/team/views.py
deleted file mode 100644
index b3bb3a06d..000000000
--- a/team/views.py
+++ /dev/null
@@ -1,16 +0,0 @@
-import logging
-
-from django.contrib import messages
-from django.http import HttpResponseRedirect
-from django.utils.translation import gettext_lazy as _
-from wagtail_modeladmin.views import CreateView, InspectView
-
-
-class CollectionTeamMemberCreateView(CreateView):
- def form_valid(self, form):
- form.save_all(self.request.user)
- messages.success(
- self.request,
- _("Member has been successfully created"),
- )
- return HttpResponseRedirect(self.get_success_url())
diff --git a/team/wagtail_hooks.py b/team/wagtail_hooks.py
index 2cdf55c1a..cfea8688b 100644
--- a/team/wagtail_hooks.py
+++ b/team/wagtail_hooks.py
@@ -1,63 +1,165 @@
-import json
-
-from django.contrib import messages
-from django.http import HttpResponseRedirect
-from django.shortcuts import get_object_or_404, redirect, render
-from django.urls import include, path
from django.utils.translation import gettext_lazy as _
-from wagtail import hooks
-from wagtail_modeladmin.options import (
- ModelAdmin,
- ModelAdminGroup,
- modeladmin_register,
-)
+from wagtail.snippets.models import register_snippet
+from wagtail.snippets.views.snippets import SnippetViewSet, SnippetViewSetGroup
from config.menu import get_menu_order
-from team.views import CollectionTeamMemberCreateView
-
-from .models import CollectionTeamMember
+from core.views import CommonControlFieldCreateView
+from core.forms import CoreAdminModelForm
+from .models import (
+ CollectionTeamMember,
+ Company,
+ CompanyTeamMember,
+ JournalCompanyContract,
+ JournalTeamMember,
+)
-class CollectionTeamMemberModelAdmin(ModelAdmin):
+class CollectionTeamMemberViewSet(SnippetViewSet):
model = CollectionTeamMember
menu_label = _("Collection Team Members")
- menu_icon = "folder"
- menu_order = 200
+ menu_icon = "group"
add_to_settings_menu = False
exclude_from_explorer = False
- create_view_class = CollectionTeamMemberCreateView
+ base_form_class = CoreAdminModelForm
+ add_view_class = CommonControlFieldCreateView
list_display = (
"user",
- "is_active_member",
"collection",
+ "role",
+ "is_active_member",
"updated",
)
- list_filter = ("is_active_member", "collection")
+ list_filter = ("role", "is_active_member", "collection")
search_fields = (
"collection__name",
"collection__acron",
"user__name",
+ "user__username",
+ "user__email",
)
def get_queryset(self, request):
+ qs = super().get_queryset(request)
if request.user.is_superuser:
- return super().get_queryset(request)
+ return qs
return CollectionTeamMember.members(request.user)
-class TeamModelAdminGroup(ModelAdminGroup):
- menu_icon = "folder"
+class CompanyViewSet(SnippetViewSet):
+ model = Company
+ menu_label = _("Companies")
+ menu_icon = "group"
+ add_to_settings_menu = False
+ exclude_from_explorer = False
+ add_view_class = CommonControlFieldCreateView
+ base_form_class = CoreAdminModelForm
+
+ list_display = (
+ "name",
+ "personal_contact",
+ "contact_email",
+ "certified_since",
+ "is_active",
+ "updated",
+ )
+ list_filter = ("is_active", "certified_since")
+ search_fields = (
+ "name",
+ "personal_contact",
+ "contact_email",
+ "url",
+ )
+
+
+class JournalTeamMemberViewSet(SnippetViewSet):
+ model = JournalTeamMember
+ menu_label = _("Journal Team Members")
+ menu_icon = "user"
+ add_to_settings_menu = False
+ exclude_from_explorer = False
+ add_view_class = CommonControlFieldCreateView
+ base_form_class = CoreAdminModelForm
+
+ list_display = (
+ "user",
+ "journal",
+ "role",
+ "is_active_member",
+ "created",
+ )
+ list_filter = ("role", "is_active_member", "created")
+ search_fields = (
+ "user__username",
+ "user__email",
+ "user__name",
+ "journal__title",
+ )
+
+
+class CompanyTeamMemberViewSet(SnippetViewSet):
+ model = CompanyTeamMember
+ menu_label = _("Company Team Members")
+ menu_icon = "user"
+ add_to_settings_menu = False
+ exclude_from_explorer = False
+ add_view_class = CommonControlFieldCreateView
+ base_form_class = CoreAdminModelForm
+
+ list_display = (
+ "user",
+ "company",
+ "role",
+ "is_active_member",
+ "created",
+ )
+ list_filter = ("role", "is_active_member", "created")
+ search_fields = (
+ "user__username",
+ "user__email",
+ "user__name",
+ "company__name",
+ )
+
+
+class JournalCompanyContractViewSet(SnippetViewSet):
+ model = JournalCompanyContract
+ menu_label = _("Journal-Company Contracts")
+ menu_icon = "doc-full"
+ add_to_settings_menu = False
+ exclude_from_explorer = False
+ add_view_class = CommonControlFieldCreateView
+ base_form_class = CoreAdminModelForm
+
+ list_display = (
+ "journal",
+ "company",
+ "is_active",
+ "start_date",
+ "end_date",
+ )
+ list_filter = ("is_active", "start_date", "end_date")
+ search_fields = (
+ "journal__title",
+ "company__name",
+ )
+
+
+class TeamViewSetGroup(SnippetViewSetGroup):
+ """
+ Group of ViewSets for Team Management
+ """
+ items = [
+ CollectionTeamMemberViewSet,
+ CompanyViewSet,
+ JournalTeamMemberViewSet,
+ CompanyTeamMemberViewSet,
+ JournalCompanyContractViewSet,
+ ]
+ menu_icon = "group"
menu_label = _("Teams")
- items = (CollectionTeamMemberModelAdmin,)
menu_order = get_menu_order("team")
-modeladmin_register(TeamModelAdminGroup)
-
+register_snippet(TeamViewSetGroup)
-# @hooks.register("register_admin_urls")
-# def register_disclosure_url():
-# return [
-# path("team/", include("team.urls", namespace="team")),
-# ]
diff --git a/tracker/models.py b/tracker/models.py
index 079eeefdb..cc6b80030 100644
--- a/tracker/models.py
+++ b/tracker/models.py
@@ -15,6 +15,7 @@
from core.forms import CoreAdminModelForm
from core.models import CommonControlField
+from core.utils.sanitize import sanitize_for_json
from tracker import choices
@@ -162,7 +163,7 @@ def create(
json.dumps(detail)
obj.detail = detail
except Exception as e:
- obj.detail = str(detail)
+ obj.detail = sanitize_for_json(detail)
if exc_traceback:
obj.traceback = traceback.format_tb(exc_traceback)
@@ -249,7 +250,7 @@ def finish(
try:
json.dumps(detail)
except Exception as exc_detail:
- detail = str(detail)
+ detail = sanitize_for_json(detail)
if detail:
self.detail = detail
diff --git a/upload/controller.py b/upload/controller.py
index fb000dc9e..ce4d7acc2 100644
--- a/upload/controller.py
+++ b/upload/controller.py
@@ -1,13 +1,8 @@
import os
import logging
-import sys
-import traceback
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
-from packtools.sps.models.dates import ArticleDates
-from packtools.sps.models.front_articlemeta_issue import ArticleMetaIssue
-from packtools.sps.models.journal_meta import ISSN, Title
from packtools.sps.pid_provider.xml_sps_lib import GetXMLItemsError, XMLWithPre
from article import choices as article_choices
@@ -16,9 +11,11 @@
from journal.models import Journal
from package.models import update_zip_file
from pid_provider.requester import PidRequester
-from proc.controller import create_or_update_journal, create_or_update_issue
+from proc.controller import (
+ JournalDataChecker,
+ IssueDataChecker,
+)
-from tracker.models import UnexpectedEvent
from upload.models import (
Package,
ValidationReport,
@@ -35,6 +32,136 @@ class UnexpectedPackageError(Exception): ...
class PackageDataError(Exception): ...
+class UploadJournalDataChecker(JournalDataChecker):
+ """Extensão de JournalDataChecker com funcionalidades específicas do fluxo de upload."""
+
+ def _build_similar_journals_msg(self):
+ """Monta mensagem com journals similares para diagnóstico."""
+ similar_journals = []
+ for j in Journal.get_similar_items(
+ self.journal_title, self.issn_electronic, self.issn_print
+ ):
+ similar_journals.append(
+ {
+ "journal_title": j.title,
+ "issn_electronic": j.official_journal.issn_electronic,
+ "issn_print": j.official_journal.issn_print,
+ }
+ )
+ if similar_journals:
+ return _("Registered journals: {}. ").format(similar_journals)
+ return _("Found no registered journal. ")
+
+ def raise_error(self):
+ """Monta e lança erro com informações detalhadas."""
+ data = {
+ "journal_title": self.journal_title,
+ "issn_electronic": self.issn_electronic,
+ "issn_print": self.issn_print,
+ }
+ similar_journals = self._build_similar_journals_msg()
+
+ if self.core_communication_error:
+ raise PackageDataError(
+ _(
+ "CORE COMMUNICATION FAILURE: Could not verify journal data. "
+ "The core API is unreachable. "
+ "Journal in XML: {}. {}"
+ ).format(data, similar_journals)
+ )
+ raise PackageDataError(
+ _(
+ "Journal in XML must be a registered journal. "
+ "Journal in XML: {}. {}. "
+ "Register the journal on core.scielo.org"
+ ).format(data, similar_journals)
+ )
+
+ def check(self, response):
+ """Executa a verificação completa de journal e atualiza response."""
+ journal = self.get_or_fetch()
+ if journal:
+ response["journal"] = journal
+ return
+
+ response["journal"] = None
+ if self.core_communication_error:
+ response["core_communication_error"] = True
+ self.raise_error()
+
+
+class UploadIssueDataChecker(IssueDataChecker):
+ """Extensão de IssueDataChecker com funcionalidades específicas do fluxo de upload."""
+
+ def _build_similar_issues_msg(self):
+ """Monta mensagem com issues similares para diagnóstico."""
+ items = None
+ if self.publication_year and self.volume:
+ items = Issue.objects.filter(
+ Q(publication_year=self.publication_year) | Q(volume=self.volume),
+ journal=self._journal,
+ )
+ elif self.publication_year:
+ items = Issue.objects.filter(
+ Q(publication_year=self.publication_year), journal=self._journal
+ )
+ if items is None or not items.exists():
+ items = Issue.objects.filter(journal=self._journal)
+
+ issues = []
+ for item in items.order_by("-publication_year"):
+ issues.append(
+ {
+ "publication_year": item.publication_year,
+ "volume": item.volume,
+ "number": item.number,
+ "supplement": item.supplement,
+ }
+ )
+ if issues:
+ return _("Registered issues: {}. ").format(issues)
+ return _("{} has no registered issues").format(self._journal)
+
+ def raise_error(self):
+ """Monta e lança erro com informações detalhadas."""
+ data = {
+ "journal": self._journal,
+ "volume": self.volume,
+ "number": self.number,
+ "suppl": self.suppl,
+ "publication_year": self.publication_year,
+ }
+ similar_issues = self._build_similar_issues_msg()
+
+ if self.core_communication_error:
+ raise PackageDataError(
+ _(
+ "CORE COMMUNICATION FAILURE: Could not verify issue data. "
+ "The core API is unreachable. "
+ "Issue in XML: {}. {}"
+ ).format(data, similar_issues)
+ )
+ raise PackageDataError(
+ _(
+ "Issue in XML must be a registered issue. "
+ "Issue in XML {}. {}. "
+ "Register the issue on core.scielo.org"
+ ).format(data, similar_issues)
+ )
+
+ def check(self, response):
+ """Executa a verificação completa de issue e atualiza response."""
+ issue = self.get_or_fetch()
+ if issue:
+ response["issue"] = issue
+ return
+
+ response["issue"] = None
+ if self.core_communication_error:
+ response["core_communication_error"] = True
+ self.raise_error()
+
+
def get_last_package(article_id, **kwargs):
try:
return (
@@ -165,15 +292,22 @@ def _check_article_and_journal(package, xml_with_pre, user):
# verifica se journal e issue estão registrados
xmltree = xml_with_pre.xmltree
- _check_journal(response, xmltree, user)
- logging.info(f"_check_journal: {response}")
- _check_issue(response, xmltree, user)
- logging.info(f"_check_issue: {response}")
+ journal_checker = UploadJournalDataChecker.from_xmltree(xmltree, user)
+ journal_checker.check(response)
+ logging.info(f"UploadJournalDataChecker.check: {response}")
+
+ issue_checker = UploadIssueDataChecker.from_xmltree(
+ xmltree, user, response["journal"]
+ )
+ issue_checker.check(response)
+ logging.info(f"UploadIssueDataChecker.check: {response}")
# verifica a consistência dos dados de journal e issue
# no XML e na base de dados
- _check_xml_and_registered_data_compability(response)
- logging.info(f"_check_xml_and_registered_data_compability: {response}")
+ _check_xml_and_registered_data_compatibility(
+ response, journal_checker, issue_checker
+ )
+ logging.info(f"_check_xml_and_registered_data_compatibility: {response}")
response["package_status"] = choices.PS_ENQUEUED_FOR_VALIDATION
@@ -250,118 +384,44 @@ def _archive_pending_correction_package(response, name):
)
-def _check_journal(response, xmltree, user):
- xml = Title(xmltree)
- journal_title = xml.journal_title
-
- xml = ISSN(xmltree)
- issn_electronic = xml.epub
- issn_print = xml.ppub
-
- response["journal"] = create_or_update_journal(
- journal_title, issn_electronic, issn_print, user
- )
-
- if not response["journal"]:
- data = {
- "journal_title": journal_title,
- "issn_electronic": issn_electronic,
- "issn_print": issn_print,
- }
- similar_journals = []
- for j in Journal.get_similar_items(journal_title, issn_electronic, issn_print):
- similar_journals.append(
- {
- "journal_title": j.title,
- "issn_electronic": j.official_journal.issn_electronic,
- "issn_print": j.official_journal.issn_print,
- }
- )
- if similar_journals:
- similar_journals = _("Registered journals: {}. ").format(similar_journals)
- else:
- similar_journals = _("Found no registered journal. ")
- raise PackageDataError(
- _(
- "Journal in XML must be a registered journal. Journal in XML: {}. {}. Register the journal on core.scielo.org"
- ).format(data, similar_journals)
- )
-
-
-def _check_issue(response, xmltree, user):
- xml = ArticleDates(xmltree)
- try:
- publication_year = xml.collection_date["year"]
- except (TypeError, KeyError, ValueError):
- try:
- publication_year = xml.article_date["year"]
- except (TypeError, KeyError, ValueError):
- publication_year = None
-
- xml = ArticleMetaIssue(xmltree)
- response["issue"] = create_or_update_issue(
- response["journal"], publication_year, xml.volume, xml.suppl, xml.number, user
- )
- logging.info(f"issue: {response['issue']}")
- if not response["issue"]:
- data = {
- "journal": response["journal"],
- "volume": xml.volume,
- "number": xml.number,
- "suppl": xml.suppl,
- "publication_year": publication_year,
- }
- logging.info(f"_check_issue {data}")
- items = None
- if publication_year and xml.volume:
- items = Issue.objects.filter(
- Q(publication_year=publication_year) | Q(volume=xml.volume),
- journal=response["journal"],
- )
- elif publication_year:
- items = Issue.objects.filter(
- Q(publication_year=publication_year), journal=response["journal"]
- )
- if not items or not items.count():
- items = Issue.objects.filter(journal=response["journal"])
-
- issues = []
- for item in items.order_by("-publication_year"):
- issues.append(
- {
- "publication_year": publication_year,
- "volume": item.volume,
- "number": item.number,
- "supplement": item.supplement,
- }
- )
- if issues:
- issues = _("Registered issues: {}. ").format(issues)
- else:
- issues = _("{} has no registered issues").format(response["journal"])
- raise PackageDataError(
- _(
- "Issue in XML must be a registered issue. Issue in XML {}. {}. Register the issue on core.scielo.org"
- ).format(data, issues)
- )
-
-
-def _check_xml_and_registered_data_compability(response):
+def _check_xml_and_registered_data_compatibility(
+ response, journal_checker, issue_checker
+):
article = response["article"]
if article:
journal = response["journal"]
if not journal == article.journal:
- raise PackageDataError(
- _("{} (registered, {}) differs from {} (XML, {})").format(
+ # divergência detectada - consulta dados remotos de journal
+ journal_checker.refresh(response)
+ journal = response["journal"]
+
+ # re-verifica após a tentativa de atualização
+ if not journal == article.journal:
+ error_msg = _("{} (registered, {}) differs from {} (XML, {})").format(
article.journal, article.journal.id, journal, journal.id
)
- )
+ if response.get("core_communication_error"):
+ error_msg = _(
+ "CORE COMMUNICATION FAILURE: {}. "
+ "Could not refresh data from core API"
+ ).format(error_msg)
+ raise PackageDataError(error_msg)
issue = response["issue"]
if not issue == article.issue:
- raise PackageDataError(
- _("{} (registered, {}) differs from {} (XML, {})").format(
+ # divergência detectada - consulta dados remotos de issue
+ issue_checker.refresh(response)
+ issue = response["issue"]
+
+ # re-verifica após a tentativa de atualização
+ if not issue == article.issue:
+ error_msg = _("{} (registered, {}) differs from {} (XML, {})").format(
article.issue, article.issue.id, issue, issue.id
)
- )
+ if response.get("core_communication_error"):
+ error_msg = _(
+ "CORE COMMUNICATION FAILURE: {}. "
+ "Could not refresh data from core API"
+ ).format(error_msg)
+ raise PackageDataError(error_msg)
diff --git a/upload/migrations/0001_initial.py b/upload/migrations/0001_initial.py
index f819f923b..25e53659f 100644
--- a/upload/migrations/0001_initial.py
+++ b/upload/migrations/0001_initial.py
@@ -14,7 +14,7 @@ class Migration(migrations.Migration):
("article", "0002_remove_articleauthor_author_and_more"),
("collection", "0003_websiteconfigurationendpoint"),
("issue", "0004_issue_issue_pid_suffix_issue_order_toc_tocsection"),
- ("journal", "0005_journaltoc_alter_journal_official_journal_and_more"),
+ ("journal", "0005_officialjournal_next_journal_title_and_more"),
("package", "0003_remove_spspkg_components_remove_spspkg_scheduled_and_more"),
("proc", "0006_issueproc_resumption_date"),
("team", "0001_initial"),
diff --git a/upload/migrations/0002_package_main_doi_and_more.py b/upload/migrations/0002_package_main_doi_and_more.py
index 3434f2692..aafef5a85 100644
--- a/upload/migrations/0002_package_main_doi_and_more.py
+++ b/upload/migrations/0002_package_main_doi_and_more.py
@@ -8,7 +8,7 @@ class Migration(migrations.Migration):
dependencies = [
("article", "0002_remove_articleauthor_author_and_more"),
("issue", "0004_issue_issue_pid_suffix_issue_order_toc_tocsection"),
- ("journal", "0005_journaltoc_alter_journal_official_journal_and_more"),
+ ("journal", "0005_officialjournal_next_journal_title_and_more"),
("package", "0003_remove_spspkg_components_remove_spspkg_scheduled_and_more"),
("team", "0001_initial"),
("upload", "0001_initial"),
diff --git a/upload/models.py b/upload/models.py
index 8d241eae9..daf5c39c1 100644
--- a/upload/models.py
+++ b/upload/models.py
@@ -1717,8 +1717,13 @@ def get_numbers(cls, package, report=None):
.values("status", "reaction")
.annotate(total=Count("id"))
):
- items["total_" + item["status"].lower()] += item["total"]
- items["total_" + item["reaction"]] += item["total"]
+ status_key = "total_" + (item.get("status") or "").lower()
+ if status_key not in items:
+ continue
+ items[status_key] += item["total"]
+ reaction_key = "total_" + (item.get("reaction") or "")
+ if reaction_key in items:
+ items[reaction_key] += item["total"]
total += item["total"]
items["total"] = total
diff --git a/upload/templates/modeladmin/upload/package/inspect.html b/upload/templates/modeladmin/upload/package/inspect.html
index fca5a9f96..228134ada 100644
--- a/upload/templates/modeladmin/upload/package/inspect.html
+++ b/upload/templates/modeladmin/upload/package/inspect.html
@@ -51,6 +51,17 @@ {% trans "Details" %}
{% block content %}
{{ block.super }}
+ {% if blocking_errors %}
+
+ {% for error in blocking_errors %}
+
+ {% endfor %}
+
+ {% endif %}
+
{% if category != 'generated-by-the-system' %}
diff --git a/upload/test_controller.py b/upload/test_controller.py
new file mode 100644
index 000000000..0ee99f845
--- /dev/null
+++ b/upload/test_controller.py
@@ -0,0 +1,562 @@
+import sys
+import unittest
+from unittest.mock import MagicMock, Mock, patch, call
+
+# Mock modules that cannot be imported in test environment
+# (upload app is not in INSTALLED_APPS, and zip_pkg module doesn't exist)
+_mock_upload_models = MagicMock()
+_mock_upload_models.choices = MagicMock()
+_mock_upload_models.choices.VAL_CAT_PACKAGE_FILE = "package-file"
+_mock_upload_models.choices.PS_ENQUEUED_FOR_VALIDATION = "enqueued-for-validation"
+_mock_upload_models.choices.PS_PENDING_CORRECTION = "pending-correction"
+_mock_upload_models.choices.PS_UNEXPECTED = "unexpected"
+_mock_upload_models.choices.VALIDATION_RESULT_BLOCKING = "BLOCKING"
+_mock_upload_models.choices.PS_WIP = ("submitted",)
+_mock_upload_models.choices.PC_NEW_DOCUMENT = "new-document"
+sys.modules.setdefault("upload.utils.zip_pkg", MagicMock())
+sys.modules.setdefault("upload.models", _mock_upload_models)
+sys.modules.setdefault("pid_provider.requester", MagicMock())
+
+from upload.controller import (
+ PackageDataError,
+ UploadJournalDataChecker,
+ UploadIssueDataChecker,
+ _check_xml_and_registered_data_compatibility,
+)
+
+
+class JournalDoesNotExist(Exception):
+ """Fake DoesNotExist exception for Journal model testing."""
+
+ pass
+
+
+class IssueDoesNotExist(Exception):
+ """Fake DoesNotExist exception for Issue model testing."""
+
+ pass
+
+
+class UploadJournalDataCheckerTestCase(unittest.TestCase):
+ """Test cases for UploadJournalDataChecker local-first lookup with core fallback."""
+
+ @patch("proc.source_core_api.Journal")
+ def test_check_returns_journal_from_local_data(self, mock_journal_cls):
+ """Test that local data is used first without querying core API."""
+ mock_journal = Mock()
+ mock_journal_cls.get_registered.return_value = mock_journal
+
+ response = {}
+ user = Mock()
+
+ checker = UploadJournalDataChecker("Test Journal", "1234-5678", "8765-4321", user)
+ checker.check(response)
+
+ self.assertEqual(response["journal"], mock_journal)
+ mock_journal_cls.get_registered.assert_called_once_with(
+ "Test Journal", "1234-5678", "8765-4321"
+ )
+
+ @patch("proc.source_core_api.fetch_and_create_journal")
+ @patch("proc.source_core_api.Journal")
+ def test_check_fetches_from_core_when_local_not_found(
+ self, mock_journal_cls, mock_fetch
+ ):
+ """Test that core API is queried when local data doesn't exist."""
+ mock_journal = Mock()
+ mock_journal_cls.DoesNotExist = JournalDoesNotExist
+ # First call: DoesNotExist, second call after core fetch: returns journal
+ mock_journal_cls.get_registered.side_effect = [
+ JournalDoesNotExist(),
+ mock_journal,
+ ]
+
+ response = {}
+ user = Mock()
+
+ checker = UploadJournalDataChecker("Test Journal", "1234-5678", "8765-4321", user)
+ checker.model = mock_journal_cls
+ checker.check(response)
+
+ self.assertEqual(response["journal"], mock_journal)
+ mock_fetch.assert_called_once_with(
+ user,
+ issn_electronic="1234-5678",
+ issn_print="8765-4321",
+ force_update=True,
+ )
+
+ @patch("upload.controller.Journal")
+ @patch("proc.source_core_api.fetch_and_create_journal")
+ @patch("proc.source_core_api.Journal")
+ def test_check_raises_error_with_core_failure_message_when_core_unreachable(
+ self, mock_journal_cls, mock_fetch, mock_upload_journal_cls
+ ):
+ """Test that core communication failure is reported when core is unreachable."""
+ from proc.source_core_api import FetchJournalDataException
+
+ mock_journal_cls.DoesNotExist = JournalDoesNotExist
+ mock_journal_cls.get_registered.side_effect = JournalDoesNotExist()
+ mock_upload_journal_cls.get_similar_items.return_value = []
+ mock_fetch.side_effect = FetchJournalDataException("Connection refused")
+
+ response = {}
+ user = Mock()
+
+ checker = UploadJournalDataChecker("Test Journal", "1234-5678", "8765-4321", user)
+ checker.model = mock_journal_cls
+ with self.assertRaises(PackageDataError) as context:
+ checker.check(response)
+
+ self.assertIn("CORE COMMUNICATION FAILURE", str(context.exception))
+ self.assertTrue(response.get("core_communication_error"))
+
+ @patch("upload.controller.Journal")
+ @patch("proc.source_core_api.fetch_and_create_journal")
+ @patch("proc.source_core_api.Journal")
+ def test_check_raises_error_without_core_failure_when_journal_not_registered(
+ self, mock_journal_cls, mock_fetch, mock_upload_journal_cls
+ ):
+ """Test that a normal error is raised when journal is not registered (core works fine)."""
+ mock_journal_cls.DoesNotExist = JournalDoesNotExist
+ mock_journal_cls.get_registered.side_effect = JournalDoesNotExist()
+ mock_upload_journal_cls.get_similar_items.return_value = []
+
+ response = {}
+ user = Mock()
+
+ checker = UploadJournalDataChecker("Test Journal", "1234-5678", "8765-4321", user)
+ checker.model = mock_journal_cls
+ with self.assertRaises(PackageDataError) as context:
+ checker.check(response)
+
+ self.assertNotIn("CORE COMMUNICATION FAILURE", str(context.exception))
+ self.assertIn("registered journal", str(context.exception))
+ self.assertFalse(response.get("core_communication_error"))
+
+ @patch("proc.source_core_api.Journal")
+ def test_check_does_not_call_core_when_local_found(self, mock_journal_cls):
+ """Test that core API is NOT called when local data exists."""
+ mock_journal = Mock()
+ mock_journal_cls.get_registered.return_value = mock_journal
+
+ response = {}
+ user = Mock()
+
+ with patch("proc.source_core_api.fetch_and_create_journal") as mock_fetch:
+ checker = UploadJournalDataChecker("Test Journal", "1234-5678", "8765-4321", user)
+ checker.check(response)
+ mock_fetch.assert_not_called()
+
+ @patch("proc.source_core_api.Journal")
+ @patch("proc.source_core_api.fetch_and_create_journal")
+ def test_refresh_updates_response_on_success(self, mock_fetch, mock_journal_cls):
+ """Test that successful core fetch updates the journal in response."""
+ mock_journal = Mock()
+ mock_journal_cls.get_registered.return_value = mock_journal
+
+ response = {"journal": None}
+ user = Mock()
+
+ checker = UploadJournalDataChecker("Test Journal", "1234-5678", "8765-4321", user)
+ checker.refresh(response)
+
+ self.assertEqual(response["journal"], mock_journal)
+ self.assertFalse(response.get("core_communication_error"))
+
+ @patch("proc.source_core_api.fetch_and_create_journal")
+ def test_refresh_sets_error_flag_on_core_failure(self, mock_fetch):
+ """Test that core API failure sets the core_communication_error flag."""
+ from proc.source_core_api import FetchJournalDataException
+
+ mock_fetch.side_effect = FetchJournalDataException("Timeout")
+
+ response = {"journal": None}
+ user = Mock()
+
+ checker = UploadJournalDataChecker("Test Journal", "1234-5678", "8765-4321", user)
+ checker.refresh(response)
+
+ self.assertTrue(response.get("core_communication_error"))
+
+ @patch("proc.source_core_api.Journal")
+ def test_get_or_fetch_returns_local_journal(self, mock_journal_cls):
+ """Test get_or_fetch returns journal from local data."""
+ mock_journal = Mock()
+ mock_journal_cls.get_registered.return_value = mock_journal
+
+ user = Mock()
+
+ checker = UploadJournalDataChecker("Test Journal", "1234-5678", "8765-4321", user)
+ result = checker.get_or_fetch()
+
+ self.assertEqual(result, mock_journal)
+
+ @patch("proc.source_core_api.fetch_and_create_journal")
+ @patch("proc.source_core_api.Journal")
+ def test_core_communication_error_resets_on_successful_fetch(
+ self, mock_journal_cls, mock_fetch
+ ):
+ """Test that core_communication_error is reset when fetch succeeds."""
+ from proc.source_core_api import FetchJournalDataException
+
+ mock_journal_cls.DoesNotExist = JournalDoesNotExist
+ user = Mock()
+
+ checker = UploadJournalDataChecker("Test Journal", "1234-5678", "8765-4321", user)
+
+ # First fetch fails
+ mock_fetch.side_effect = FetchJournalDataException("Timeout")
+ checker.fetch_from_core()
+ self.assertTrue(checker.core_communication_error)
+
+ # Second fetch succeeds
+ mock_fetch.side_effect = None
+ checker.fetch_from_core()
+ self.assertFalse(checker.core_communication_error)
+
+ @patch("packtools.sps.models.journal_meta.ISSN")
+ @patch("packtools.sps.models.journal_meta.Title")
+ def test_from_xmltree_creates_checker(self, mock_title_cls, mock_issn_cls):
+ """Test that from_xmltree correctly creates a checker from XML data."""
+ mock_title = Mock()
+ mock_title.journal_title = "Test Journal"
+ mock_title_cls.return_value = mock_title
+
+ mock_issn = Mock()
+ mock_issn.epub = "1234-5678"
+ mock_issn.ppub = "8765-4321"
+ mock_issn_cls.return_value = mock_issn
+
+ xmltree = Mock()
+ user = Mock()
+
+ checker = UploadJournalDataChecker.from_xmltree(xmltree, user)
+
+ self.assertEqual(checker.journal_title, "Test Journal")
+ self.assertEqual(checker.issn_electronic, "1234-5678")
+ self.assertEqual(checker.issn_print, "8765-4321")
+ self.assertIsInstance(checker, UploadJournalDataChecker)
+
+
+class UploadIssueDataCheckerTestCase(unittest.TestCase):
+ """Test cases for UploadIssueDataChecker local-first lookup with core fallback."""
+
+ @patch("proc.source_core_api.Issue")
+ def test_check_returns_issue_from_local_data(self, mock_issue_cls):
+ """Test that local data is used first without querying core API."""
+ mock_issue = Mock()
+ mock_issue_cls.get.return_value = mock_issue
+
+ mock_journal = Mock()
+ response = {"journal": mock_journal}
+ user = Mock()
+
+ checker = UploadIssueDataChecker(mock_journal, "2024", "10", None, "1", user)
+ checker.check(response)
+
+ self.assertEqual(response["issue"], mock_issue)
+ mock_issue_cls.get.assert_called_once_with(
+ journal=mock_journal,
+ volume="10",
+ supplement=None,
+ number="1",
+ )
+
+ @patch("proc.source_core_api.fetch_and_create_issues")
+ @patch("proc.source_core_api.Issue")
+ def test_check_fetches_from_core_when_local_not_found(
+ self, mock_issue_cls, mock_fetch
+ ):
+ """Test that core API is queried when local data doesn't exist."""
+ mock_issue = Mock()
+ mock_issue_cls.DoesNotExist = IssueDoesNotExist
+ # First call: DoesNotExist, second call after core fetch: returns issue
+ mock_issue_cls.get.side_effect = [
+ IssueDoesNotExist(),
+ mock_issue,
+ ]
+
+ mock_journal = Mock()
+ response = {"journal": mock_journal}
+ user = Mock()
+
+ checker = UploadIssueDataChecker(mock_journal, "2024", "10", None, "1", user)
+ checker.model = mock_issue_cls
+ checker.check(response)
+
+ self.assertEqual(response["issue"], mock_issue)
+ mock_fetch.assert_called_once_with(
+ mock_journal, "2024", "10", None, "1", user
+ )
+
+ @patch("upload.controller.Issue")
+ @patch("proc.source_core_api.fetch_and_create_issues")
+ @patch("proc.source_core_api.Issue")
+ def test_check_raises_error_with_core_failure_message_when_core_unreachable(
+ self, mock_issue_cls, mock_fetch, mock_upload_issue_cls
+ ):
+ """Test that core communication failure is reported when core is unreachable."""
+ from proc.source_core_api import FetchIssueDataException
+
+ mock_issue_cls.DoesNotExist = IssueDoesNotExist
+ mock_issue_cls.get.side_effect = IssueDoesNotExist()
+ mock_fetch.side_effect = FetchIssueDataException("Connection refused")
+
+ mock_qs = MagicMock()
+ mock_qs.exists.return_value = False
+ mock_qs.order_by.return_value = []
+ mock_upload_issue_cls.objects.filter.return_value = mock_qs
+
+ mock_journal = Mock()
+ response = {"journal": mock_journal}
+ user = Mock()
+
+ checker = UploadIssueDataChecker(mock_journal, "2024", "10", None, "1", user)
+ checker.model = mock_issue_cls
+ with self.assertRaises(PackageDataError) as context:
+ checker.check(response)
+
+ self.assertIn("CORE COMMUNICATION FAILURE", str(context.exception))
+ self.assertTrue(response.get("core_communication_error"))
+
+ @patch("proc.source_core_api.Issue")
+ def test_check_does_not_call_core_when_local_found(self, mock_issue_cls):
+ """Test that core API is NOT called when local data exists."""
+ mock_issue = Mock()
+ mock_issue_cls.get.return_value = mock_issue
+
+ mock_journal = Mock()
+ response = {"journal": mock_journal}
+ user = Mock()
+
+ with patch("proc.source_core_api.fetch_and_create_issues") as mock_fetch:
+ checker = UploadIssueDataChecker(mock_journal, "2024", "10", None, "1", user)
+ checker.check(response)
+ mock_fetch.assert_not_called()
+
+ @patch("proc.source_core_api.Issue")
+ @patch("proc.source_core_api.fetch_and_create_issues")
+ def test_refresh_updates_response_on_success(self, mock_fetch, mock_issue_cls):
+ """Test that successful core fetch updates the issue in response."""
+ mock_issue = Mock()
+ mock_issue_cls.get.return_value = mock_issue
+
+ mock_journal = Mock()
+ response = {"journal": mock_journal, "issue": None}
+ user = Mock()
+
+ checker = UploadIssueDataChecker(mock_journal, "2024", "10", None, "1", user)
+ checker.refresh(response)
+
+ self.assertEqual(response["issue"], mock_issue)
+ self.assertFalse(response.get("core_communication_error"))
+
+ @patch("proc.source_core_api.fetch_and_create_issues")
+ def test_refresh_sets_error_flag_on_core_failure(self, mock_fetch):
+ """Test that core API failure sets the core_communication_error flag."""
+ from proc.source_core_api import FetchIssueDataException
+
+ mock_fetch.side_effect = FetchIssueDataException("Timeout")
+
+ mock_journal = Mock()
+ response = {"journal": mock_journal, "issue": None}
+ user = Mock()
+
+ checker = UploadIssueDataChecker(mock_journal, "2024", "10", None, "1", user)
+ checker.refresh(response)
+
+ self.assertTrue(response.get("core_communication_error"))
+
+ @patch("proc.source_core_api.fetch_and_create_issues")
+ @patch("proc.source_core_api.Issue")
+ def test_core_communication_error_resets_on_successful_fetch(
+ self, mock_issue_cls, mock_fetch
+ ):
+ """Test that core_communication_error is reset when fetch succeeds."""
+ from proc.source_core_api import FetchIssueDataException
+
+ mock_issue_cls.DoesNotExist = IssueDoesNotExist
+ mock_journal = Mock()
+ user = Mock()
+
+ checker = UploadIssueDataChecker(mock_journal, "2024", "10", None, "1", user)
+
+ # First fetch fails
+ mock_fetch.side_effect = FetchIssueDataException("Timeout")
+ checker.fetch_from_core()
+ self.assertTrue(checker.core_communication_error)
+
+ # Second fetch succeeds
+ mock_fetch.side_effect = None
+ checker.fetch_from_core()
+ self.assertFalse(checker.core_communication_error)
+
+ @patch("packtools.sps.models.front_articlemeta_issue.ArticleMetaIssue")
+ @patch("packtools.sps.models.dates.ArticleDates")
+ def test_from_xmltree_creates_checker(self, mock_dates_cls, mock_meta_cls):
+ """Test that from_xmltree correctly creates a checker from XML data."""
+ mock_dates = Mock()
+ mock_dates.collection_date = {"year": "2024"}
+ mock_dates_cls.return_value = mock_dates
+
+ mock_meta = Mock()
+ mock_meta.volume = "10"
+ mock_meta.suppl = None
+ mock_meta.number = "1"
+ mock_meta_cls.return_value = mock_meta
+
+ xmltree = Mock()
+ user = Mock()
+ mock_journal = Mock()
+
+ checker = UploadIssueDataChecker.from_xmltree(xmltree, user, mock_journal)
+
+ self.assertEqual(checker.publication_year, "2024")
+ self.assertEqual(checker.volume, "10")
+ self.assertEqual(checker.number, "1")
+ self.assertIsNone(checker.suppl)
+ self.assertIsInstance(checker, UploadIssueDataChecker)
+
+
+class CheckXmlAndRegisteredDataCompatibilityTestCase(unittest.TestCase):
+ """Test cases for _check_xml_and_registered_data_compatibility()."""
+
+ def test_no_article_does_nothing(self):
+ """Test that function returns without error when there is no article."""
+ response = {"article": None, "journal": Mock(), "issue": Mock()}
+ journal_checker = Mock()
+ issue_checker = Mock()
+
+ _check_xml_and_registered_data_compatibility(
+ response, journal_checker, issue_checker
+ )
+
+ def test_matching_journal_and_issue_passes(self):
+ """Test that function passes when journal and issue match."""
+ mock_journal = Mock()
+ mock_issue = Mock()
+ mock_article = Mock()
+ mock_article.journal = mock_journal
+ mock_article.issue = mock_issue
+
+ response = {
+ "article": mock_article,
+ "journal": mock_journal,
+ "issue": mock_issue,
+ }
+ journal_checker = Mock()
+ issue_checker = Mock()
+
+ _check_xml_and_registered_data_compatibility(
+ response, journal_checker, issue_checker
+ )
+
+ def test_journal_divergence_triggers_core_refresh(self):
+ """Test that journal divergence triggers a refresh from core."""
+ mock_journal_xml = Mock()
+ mock_journal_article = Mock()
+ mock_issue = Mock()
+ mock_article = Mock()
+ mock_article.journal = mock_journal_article
+ mock_article.issue = mock_issue
+
+ response = {
+ "article": mock_article,
+ "journal": mock_journal_xml,
+ "issue": mock_issue,
+ }
+
+ journal_checker = Mock()
+ issue_checker = Mock()
+
+ with self.assertRaises(PackageDataError):
+ _check_xml_and_registered_data_compatibility(
+ response, journal_checker, issue_checker
+ )
+
+ journal_checker.refresh.assert_called_once()
+
+ def test_journal_divergence_resolved_after_refresh(self):
+ """Test that no error is raised when divergence is resolved after refresh."""
+ mock_journal = Mock()
+ mock_issue = Mock()
+ mock_article = Mock()
+ mock_article.journal = mock_journal
+ mock_article.issue = mock_issue
+
+ # Initially journal differs
+ mock_journal_xml = Mock()
+ response = {
+ "article": mock_article,
+ "journal": mock_journal_xml,
+ "issue": mock_issue,
+ }
+
+ journal_checker = Mock()
+ issue_checker = Mock()
+
+ # After refresh, journal matches
+ def refresh_side_effect(response):
+ response["journal"] = mock_journal
+
+ journal_checker.refresh.side_effect = refresh_side_effect
+
+ _check_xml_and_registered_data_compatibility(
+ response, journal_checker, issue_checker
+ )
+
+ def test_journal_divergence_with_core_failure_includes_core_error_message(self):
+ """Test that core communication failure is mentioned when divergence persists and core failed."""
+ mock_journal_xml = Mock()
+ mock_journal_article = Mock()
+ mock_issue = Mock()
+ mock_article = Mock()
+ mock_article.journal = mock_journal_article
+ mock_article.issue = mock_issue
+
+ response = {
+ "article": mock_article,
+ "journal": mock_journal_xml,
+ "issue": mock_issue,
+ }
+
+ journal_checker = Mock()
+ issue_checker = Mock()
+
+ def refresh_side_effect(response):
+ response["core_communication_error"] = True
+
+ journal_checker.refresh.side_effect = refresh_side_effect
+
+ with self.assertRaises(PackageDataError) as context:
+ _check_xml_and_registered_data_compatibility(
+ response, journal_checker, issue_checker
+ )
+
+ self.assertIn("CORE COMMUNICATION FAILURE", str(context.exception))
+
+ def test_issue_divergence_triggers_core_refresh(self):
+ """Test that issue divergence triggers a refresh from core."""
+ mock_journal = Mock()
+ mock_issue_xml = Mock()
+ mock_issue_article = Mock()
+ mock_article = Mock()
+ mock_article.journal = mock_journal
+ mock_article.issue = mock_issue_article
+
+ response = {
+ "article": mock_article,
+ "journal": mock_journal,
+ "issue": mock_issue_xml,
+ }
+
+ journal_checker = Mock()
+ issue_checker = Mock()
+
+ with self.assertRaises(PackageDataError):
+ _check_xml_and_registered_data_compatibility(
+ response, journal_checker, issue_checker
+ )
+
+ issue_checker.refresh.assert_called_once()
diff --git a/upload/views.py b/upload/views.py
index fc9e64684..ce5c1b2db 100644
--- a/upload/views.py
+++ b/upload/views.py
@@ -8,7 +8,7 @@
from article.models import Article
from issue.models import Issue
-from upload.models import Package, choices
+from upload.models import Package, PkgValidationResult, choices
from upload.tasks import (
task_receive_packages,
task_publish_article,
@@ -82,6 +82,12 @@ def set_pdf_paths(self, data, optz_dir):
data["pdfs"] = []
def get_context_data(self):
+ blocking_errors = list(
+ PkgValidationResult.objects.filter(
+ report__package=self.instance,
+ status=choices.VALIDATION_RESULT_BLOCKING,
+ ).values_list("message", flat=True)
+ )
data = {
"pkg_zip_name": self.instance.pkg_zip.name,
"linked": self.instance.linked.all(),
@@ -97,6 +103,7 @@ def get_context_data(self):
"xml_info_reports": list(self.instance.xml_info_reports),
"summary": self.instance.summary,
"xml": self.instance.xml,
+ "blocking_errors": blocking_errors,
}
# optz_file_path, optz_dir = self.get_optimized_package_filepath_and_directory()
diff --git a/upload/wagtail_hooks.py b/upload/wagtail_hooks.py
index 00c48868e..9cfc39bef 100644
--- a/upload/wagtail_hooks.py
+++ b/upload/wagtail_hooks.py
@@ -5,11 +5,8 @@
from django.db.models import Q
from wagtail import hooks
-from wagtail_modeladmin.options import (
- ModelAdmin,
- ModelAdminGroup,
- modeladmin_register,
-)
+from wagtail.snippets.models import register_snippet
+from wagtail.snippets.views.snippets import SnippetViewSet, SnippetViewSetGroup
from config.menu import get_menu_order
from upload.views import (
@@ -42,18 +39,15 @@
from team.models import has_permission
-class PackageZipAdmin(ModelAdmin):
+class PackageZipViewSet(SnippetViewSet):
model = PackageZip
# button_helper_class = UploadButtonHelper
permission_helper_class = UploadPermissionHelper
- create_view_enabled = True
- create_view_class = PackageZipCreateView
- inspect_view_enabled = False
+ add_view_class = PackageZipCreateView
menu_label = _("Package upload")
menu_icon = "folder"
menu_order = 200
add_to_settings_menu = False
- exclude_from_explorer = False
list_per_page = 20
list_display = (
@@ -80,20 +74,16 @@ def get_queryset(self, request):
return super().get_queryset(request).filter(**params)
-class PackageAdmin(ModelAdmin):
+class PackageViewSet(SnippetViewSet):
model = Package
button_helper_class = UploadButtonHelper
permission_helper_class = UploadPermissionHelper
- create_view_enabled = False
- # create_view_class = PackageCreateView
- inspect_view_enabled = True
inspect_view_class = PackageAdminInspectView
inspect_template_name = "modeladmin/upload/package/inspect.html"
menu_label = _("Package admin")
menu_icon = "folder"
menu_order = 200
add_to_settings_menu = False
- exclude_from_explorer = False
list_per_page = 20
list_display = (
@@ -196,7 +186,7 @@ def get_queryset(self, request):
return super().get_queryset(request).filter(status__in=status, **params)
-class QualityAnalysisPackageAdmin(ModelAdmin):
+class QualityAnalysisPackageViewSet(SnippetViewSet):
model = QAPackage
button_helper_class = UploadButtonHelper
permission_helper_class = UploadPermissionHelper
@@ -204,11 +194,9 @@ class QualityAnalysisPackageAdmin(ModelAdmin):
menu_icon = "folder"
menu_order = 200
edit_view_class = QAPackageEditView
- inspect_view_enabled = True
inspect_view_class = PackageAdminInspectView
inspect_template_name = "modeladmin/upload/package/inspect.html"
add_to_settings_menu = False
- exclude_from_explorer = False
list_per_page = 20
list_display = (
@@ -278,7 +266,7 @@ def get_queryset(self, request):
return super().get_queryset(request).filter(status__in=status, **params)
-class ReadyToPublishPackageAdmin(ModelAdmin):
+class ReadyToPublishPackageViewSet(SnippetViewSet):
model = ReadyToPublishPackage
button_helper_class = UploadButtonHelper
@@ -287,11 +275,9 @@ class ReadyToPublishPackageAdmin(ModelAdmin):
menu_icon = "folder"
menu_order = 200
edit_view_class = ReadyToPublishPackageEditView
- inspect_view_enabled = True
inspect_view_class = PackageAdminInspectView
inspect_template_name = "modeladmin/upload/package/inspect.html"
add_to_settings_menu = False
- exclude_from_explorer = False
list_per_page = 20
list_display = (
@@ -343,17 +329,15 @@ def get_queryset(self, request):
return super().get_queryset(request).filter(status__in=status, **params)
-class XMLErrorReportAdmin(ModelAdmin):
+class XMLErrorReportViewSet(SnippetViewSet):
model = XMLErrorReport
permission_helper_class = UploadPermissionHelper
edit_view_class = XMLErrorReportEditView
# create_view_class = XMLErrorReportCreateView
- inspect_view_enabled = True
# inspect_view_class = XMLErrorReportAdminInspectView
menu_label = _("XML Error Reports")
menu_icon = "error"
add_to_settings_menu = False
- exclude_from_explorer = False
list_display = (
"package",
"category",
@@ -380,16 +364,14 @@ def get_queryset(self, request):
return super().get_queryset(request).filter(package__creator=request.user)
-class XMLErrorAdmin(ModelAdmin):
+class XMLErrorViewSet(SnippetViewSet):
model = XMLError
permission_helper_class = UploadPermissionHelper
# create_view_class = XMLErrorCreateView
- inspect_view_enabled = True
# inspect_view_class = XMLErrorAdminInspectView
menu_label = _("XML errors")
menu_icon = "error"
add_to_settings_menu = False
- exclude_from_explorer = False
list_display = (
"subject",
"attribute",
@@ -422,17 +404,15 @@ def get_queryset(self, request):
return super().get_queryset(request).filter(package__creator=request.user)
-class XMLInfoReportAdmin(ModelAdmin):
+class XMLInfoReportViewSet(SnippetViewSet):
model = XMLInfoReport
permission_helper_class = UploadPermissionHelper
edit_view_class = XMLInfoReportEditView
# create_view_class = XMLInfoReportCreateView
- inspect_view_enabled = True
# inspect_view_class = XMLInfoReportAdminInspectView
menu_label = _("XML Info Reports")
menu_icon = "error"
add_to_settings_menu = False
- exclude_from_explorer = False
list_display = (
"package",
"category",
@@ -459,16 +439,14 @@ def get_queryset(self, request):
return super().get_queryset(request).filter(package__creator=request.user)
-class XMLInfoAdmin(ModelAdmin):
+class XMLInfoViewSet(SnippetViewSet):
model = XMLInfo
permission_helper_class = UploadPermissionHelper
# create_view_class = XMLInfoCreateView
- inspect_view_enabled = True
# inspect_view_class = XMLInfoAdminInspectView
menu_label = _("XML info")
menu_icon = "error"
add_to_settings_menu = False
- exclude_from_explorer = False
list_display = (
"subject",
"attribute",
@@ -501,18 +479,16 @@ def get_queryset(self, request):
return super().get_queryset(request).filter(package__creator=request.user)
-class ValidationReportAdmin(ModelAdmin):
+class ValidationReportViewSet(SnippetViewSet):
model = ValidationReport
permission_helper_class = UploadPermissionHelper
# create_view_class = ValidationReportCreateView
edit_view_class = ValidationReportEditView
- inspect_view_enabled = True
# inspect_view_class = ValidationReportAdminInspectView
menu_label = _("Validation Reports")
menu_icon = "error"
add_to_settings_menu = False
- exclude_from_explorer = False
list_display = (
"package",
"category",
@@ -539,16 +515,14 @@ def get_queryset(self, request):
return super().get_queryset(request).filter(package__creator=request.user)
-class ValidationAdmin(ModelAdmin):
+class ValidationViewSet(SnippetViewSet):
model = PkgValidationResult
permission_helper_class = UploadPermissionHelper
# create_view_class = ValidationCreateView
- inspect_view_enabled = True
# inspect_view_class = ValidationAdminInspectView
menu_label = _("Validations")
menu_icon = "error"
add_to_settings_menu = False
- exclude_from_explorer = False
list_display = (
"subject",
"status",
@@ -572,16 +546,14 @@ def get_queryset(self, request):
return super().get_queryset(request).filter(package__creator=request.user)
-class UploadValidatorAdmin(ModelAdmin):
+class UploadValidatorViewSet(SnippetViewSet):
model = UploadValidator
permission_helper_class = UploadPermissionHelper
# create_view_class = ValidationCreateView
- inspect_view_enabled = False
# inspect_view_class = ValidationAdminInspectView
menu_label = _("Upload Validator")
menu_icon = "folder"
add_to_settings_menu = False
- exclude_from_explorer = False
list_display = (
"collection",
"max_xml_warnings_percentage",
@@ -605,20 +577,16 @@ def get_queryset(self, request):
return super().get_queryset(request).none()
-class ArchivedPackageAdmin(ModelAdmin):
+class ArchivedPackageViewSet(SnippetViewSet):
model = ArchivedPackage
button_helper_class = UploadButtonHelper
permission_helper_class = UploadPermissionHelper
- create_view_enabled = False
- # create_view_class = PackageCreateView
- inspect_view_enabled = True
inspect_view_class = PackageAdminInspectView
inspect_template_name = "modeladmin/upload/package/inspect.html"
menu_label = _("Archived Packages")
menu_icon = "folder"
menu_order = 200
add_to_settings_menu = False
- exclude_from_explorer = False
list_per_page = 20
list_display = (
@@ -672,39 +640,39 @@ def get_queryset(self, request):
)
-class UploadModelAdminGroup(ModelAdminGroup):
+class UploadViewSetGroup(SnippetViewSetGroup):
menu_icon = "folder"
menu_label = "Upload"
- items = (
- PackageZipAdmin,
- PackageAdmin,
- QualityAnalysisPackageAdmin,
- ReadyToPublishPackageAdmin,
- ArchivedPackageAdmin,
- )
+ items = [
+ PackageZipViewSet,
+ PackageViewSet,
+ QualityAnalysisPackageViewSet,
+ ReadyToPublishPackageViewSet,
+ ArchivedPackageViewSet,
+ ]
menu_order = get_menu_order("upload")
-modeladmin_register(UploadModelAdminGroup)
+register_snippet(UploadViewSetGroup)
-class UploadReportsModelAdminGroup(ModelAdminGroup):
+class UploadReportsViewSetGroup(SnippetViewSetGroup):
menu_icon = "folder"
menu_label = _("Error management")
- items = (
+ items = [
# os itens a seguir possibilitam que na página Package.inspect
# funcionem os links para os relatórios
- XMLErrorAdmin,
- XMLErrorReportAdmin,
- XMLInfoReportAdmin,
- ValidationAdmin,
- ValidationReportAdmin,
- UploadValidatorAdmin,
- )
+ XMLErrorViewSet,
+ XMLErrorReportViewSet,
+ XMLInfoReportViewSet,
+ ValidationViewSet,
+ ValidationReportViewSet,
+ UploadValidatorViewSet,
+ ]
menu_order = get_menu_order("upload-error")
-modeladmin_register(UploadReportsModelAdminGroup)
+register_snippet(UploadReportsViewSetGroup)
@hooks.register("register_admin_urls")