From 8d9d10099a6f9544625f3d6bed6c3e96e5105ddd Mon Sep 17 00:00:00 2001 From: Kajetan Ramsza Date: Mon, 17 Nov 2025 23:54:09 +0100 Subject: [PATCH 1/6] add the constraints --- oioioi/problems/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/oioioi/problems/models.py b/oioioi/problems/models.py index e38c740fe..e78511f90 100644 --- a/oioioi/problems/models.py +++ b/oioioi/problems/models.py @@ -797,6 +797,7 @@ def __str__(self): return str(self.problem.name) + " -- " + str(self.tag.name) class Meta: + unique_together = ("problem", "user") verbose_name = _("difficulty proposal") verbose_name_plural = _("difficulty proposals") @@ -878,6 +879,7 @@ def __str__(self): return str(self.problem.name) + " -- " + str(self.tag.name) class Meta: + unique_together = ("problem", "tag", "user") verbose_name = _("algorithm tag proposal") verbose_name_plural = _("algorithm tag proposals") From 7438e860c4550b0611d96c729b6e656310628121 Mon Sep 17 00:00:00 2001 From: Kajetan Ramsza Date: Mon, 17 Nov 2025 23:54:18 +0100 Subject: [PATCH 2/6] add migration --- ...thmtagproposal_unique_together_and_more.py | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 oioioi/problems/migrations/0039_alter_algorithmtagproposal_unique_together_and_more.py diff --git a/oioioi/problems/migrations/0039_alter_algorithmtagproposal_unique_together_and_more.py b/oioioi/problems/migrations/0039_alter_algorithmtagproposal_unique_together_and_more.py new file mode 100644 index 000000000..1a0478c8d --- /dev/null +++ b/oioioi/problems/migrations/0039_alter_algorithmtagproposal_unique_together_and_more.py @@ -0,0 +1,64 @@ +# Generated by Django 5.2.7 on 2025-11-17 22:44 + +from django.conf import settings +from django.db import migrations +from django.db.models import Count + +def cleanup_duplicate_proposals(apps, schema_editor): + AlgorithmTagProposal = apps.get_model('problems', 'AlgorithmTagProposal') + DifficultyTagProposal = apps.get_model('problems', 'DifficultyTagProposal') + + # Cleanup AlgorithmTagProposal + duplicates = ( + AlgorithmTagProposal.objects.values('problem', 'tag', 'user') + .annotate(count=Count('id')) + .filter(count__gt=1) + ) + + for duplicate in duplicates: + proposals = AlgorithmTagProposal.objects.filter( + problem=duplicate['problem'], + tag=duplicate['tag'], + user=duplicate['user'] + ).order_by('id') + + # Keep the first one, delete the rest + for proposal in proposals[1:]: + proposal.delete() + + # Cleanup DifficultyTagProposal + duplicates = ( + DifficultyTagProposal.objects.values('problem', 'user') + .annotate(count=Count('id')) + .filter(count__gt=1) + ) + + for duplicate in duplicates: + proposals = DifficultyTagProposal.objects.filter( + problem=duplicate['problem'], + user=duplicate['user'] + ).order_by('id') + + # Keep the first one, delete the rest + for proposal in proposals[1:]: + proposal.delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('problems', '0038_alter_algorithmtaglocalization_language_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RunPython(cleanup_duplicate_proposals), + migrations.AlterUniqueTogether( + name='algorithmtagproposal', + unique_together={('problem', 'tag', 'user')}, + ), + migrations.AlterUniqueTogether( + name='difficultytagproposal', + unique_together={('problem', 'user')}, + ), + ] From a2d1375f7c60b5e0f669b7c65cc4526a3e14e05a Mon Sep 17 00:00:00 2001 From: Kajetan Ramsza Date: Wed, 3 Dec 2025 15:03:06 +0100 Subject: [PATCH 3/6] feat: return bad request on duplicate tag --- oioioi/problems/views.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/oioioi/problems/views.py b/oioioi/problems/views.py index 0165e8130..cb958bbcf 100644 --- a/oioioi/problems/views.py +++ b/oioioi/problems/views.py @@ -1156,8 +1156,16 @@ def save_proposals_view(request): if not tags or not difficulty or not user or not problem: return HttpResponseBadRequest() + if DifficultyTagProposal.objects.filter(problem=problem, user=user).exists(): + return HttpResponseBadRequest() + for tag in tags: algorithm_tag = AlgorithmTagLocalization.objects.filter(full_name=tag, language=get_language()).first().algorithm_tag + + + if AlgorithmTagProposal.objects.filter(problem=problem, tag=algorithm_tag, user=user).exists(): + return HttpResponseBadRequest() + algorithm_tag_proposal = AlgorithmTagProposal(problem=problem, tag=algorithm_tag, user=user) algorithm_tag_proposal.save() From 913de18ce0875a7549e94f7d606ddf47d423691e Mon Sep 17 00:00:00 2001 From: Kajetan Ramsza Date: Wed, 3 Dec 2025 15:09:25 +0100 Subject: [PATCH 4/6] feat: unit test for duplicate difficulty tag --- oioioi/problems/tests/test_tag_proposals.py | 28 +++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/oioioi/problems/tests/test_tag_proposals.py b/oioioi/problems/tests/test_tag_proposals.py index 245bca84e..228fcc579 100644 --- a/oioioi/problems/tests/test_tag_proposals.py +++ b/oioioi/problems/tests/test_tag_proposals.py @@ -266,3 +266,31 @@ def test_save_proposals_view(self): for q_data in invalid_query_data: response = self.client.post(self.url, q_data) self.assertEqual(response.status_code, 400) + + def test_duplicate_difficulty_proposal_rejected(self): + problem = Problem.objects.get(pk=0) + user = User.objects.get(username="test_admin") + + response = self.client.post( + self.url, + { + "tags[]": ["Dynamic programming"], + "difficulty": "Easy", + "user": "test_admin", + "problem": "0", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(DifficultyTagProposal.objects.filter(problem=problem, user=user).count(), 1) + + response = self.client.post( + self.url, + { + "tags[]": ["Greedy"], + "difficulty": "Medium", + "user": "test_admin", + "problem": "0", + }, + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(DifficultyTagProposal.objects.filter(problem=problem, user=user).count(), 1) From 48fbf4285626634e12fb31e915b7e2fb405a9e1f Mon Sep 17 00:00:00 2001 From: Kajetan Ramsza Date: Wed, 10 Dec 2025 16:45:32 +0100 Subject: [PATCH 5/6] fix: mass_create_tool breaks uniqueness constraint on tag proposals --- .../management/commands/mass_create_tool.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/oioioi/problems/management/commands/mass_create_tool.py b/oioioi/problems/management/commands/mass_create_tool.py index 305e731fa..1f37c3ea2 100644 --- a/oioioi/problems/management/commands/mass_create_tool.py +++ b/oioioi/problems/management/commands/mass_create_tool.py @@ -191,9 +191,18 @@ def create_proposals(self, count, problems, users, tags, proposal_model, verbose """ objs = [] for i in range(count): - problem = random.choice(problems) - user = random.choice(users) - tag = random.choice(tags) + def candidate_fn(): + return (random.choice(problems), random.choice(users), random.choice(tags)) + + def uniqueness_fn(candidate): + problem, user, tag = candidate + if proposal_model.__name__ == 'AlgorithmTagProposal': + return not proposal_model.objects.filter(problem=problem, user=user, tag=tag).exists() + elif proposal_model.__name__ == 'DifficultyTagProposal': + return not proposal_model.objects.filter(problem=problem, user=user).exists() + return True + + problem, user, tag = get_unique_candidate(candidate_fn, uniqueness_fn) proposal = proposal_model(problem=problem, user=user, tag=tag) proposal.save() objs.append(proposal) From 52cbaede2bdec896924a6590024e21c554585a01 Mon Sep 17 00:00:00 2001 From: Kajetan Ramsza Date: Wed, 17 Dec 2025 17:52:55 +0100 Subject: [PATCH 6/6] feat: optimize deletion requests --- ...thmtagproposal_unique_together_and_more.py | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/oioioi/problems/migrations/0039_alter_algorithmtagproposal_unique_together_and_more.py b/oioioi/problems/migrations/0039_alter_algorithmtagproposal_unique_together_and_more.py index 1a0478c8d..e684c97aa 100644 --- a/oioioi/problems/migrations/0039_alter_algorithmtagproposal_unique_together_and_more.py +++ b/oioioi/problems/migrations/0039_alter_algorithmtagproposal_unique_together_and_more.py @@ -16,15 +16,13 @@ def cleanup_duplicate_proposals(apps, schema_editor): ) for duplicate in duplicates: - proposals = AlgorithmTagProposal.objects.filter( - problem=duplicate['problem'], - tag=duplicate['tag'], - user=duplicate['user'] - ).order_by('id') - - # Keep the first one, delete the rest - for proposal in proposals[1:]: - proposal.delete() + AlgorithmTagProposal.objects.filter( + pk__in=AlgorithmTagProposal.objects.filter( + problem=duplicate["problem"], + tag=duplicate["tag"], + user=duplicate["user"] + ).values_list("id", flat=True)[1:] + ).delete() # Cleanup DifficultyTagProposal duplicates = ( @@ -34,14 +32,12 @@ def cleanup_duplicate_proposals(apps, schema_editor): ) for duplicate in duplicates: - proposals = DifficultyTagProposal.objects.filter( - problem=duplicate['problem'], - user=duplicate['user'] - ).order_by('id') - - # Keep the first one, delete the rest - for proposal in proposals[1:]: - proposal.delete() + DifficultyTagProposal.objects.filter( + pk__in=DifficultyTagProposal.objects.filter( + problem=duplicate["problem"], + user=duplicate["user"] + ).values_list("id", flat=True)[1:] + ).delete() class Migration(migrations.Migration):