diff --git a/backend/data/admin.py b/backend/data/admin.py index 7c9a4924..79e65f81 100644 --- a/backend/data/admin.py +++ b/backend/data/admin.py @@ -4,8 +4,7 @@ class PersonNameAdmin(admin.StackedInline): model = models.PersonName - fields = ['value', 'certainty', 'note'] - + fields = ["value", "certainty", "note"] @admin.register(models.Person) @@ -13,55 +12,104 @@ class PersonAdmin(admin.ModelAdmin): inlines = [PersonNameAdmin] -class EpistolaryEventCategoryAdmin(admin.StackedInline): - model = models.EpistolaryEventCategory - fields = ['value', 'certainty', 'note'] - +class LetterActionCategoryAdmin(admin.StackedInline): + model = models.LetterActionCategory + fields = ["value", "certainty", "note"] extra = 0 + verbose_name = "category" + verbose_name_plural = "categories" class EventDateAdmin(admin.StackedInline): - model = models.EpistolaryEventDate - fields = ['year_exact', 'year_lower', 'year_upper', 'certainty', 'note'] + model = models.LetterEventDate + fields = ["year_exact", "year_lower", "year_upper", "certainty", "note"] + verbose_name = "date" + verbose_name_plural = "dates" + class RoleAdmin(admin.StackedInline): model = models.Role fields = [ - 'person', - 'present', - 'role', - 'description', - 'certainty', - 'note', + "person", + "present", + "role", + "description", + "certainty", + "note", ] - extra = 0 + verbose_name = "person/role" + verbose_name_plural = "persons/roles involved" -class EventLetterAdmin(admin.StackedInline): - model = models.EpistolaryEvent.letters.through +class LetterActionLettersAdmin(admin.StackedInline): + model = models.LetterAction.letters.through extra = 0 + verbose_name = "letter" + verbose_name_plural = "letters" -@admin.register(models.EpistolaryEvent) -class EpistolaryEventAdmin(admin.ModelAdmin): + +@admin.register(models.LetterAction) +class LetterActionAdmin(admin.ModelAdmin): inlines = [ - EventLetterAdmin, - EpistolaryEventCategoryAdmin, + LetterActionLettersAdmin, + LetterActionCategoryAdmin, EventDateAdmin, RoleAdmin, ] + exclude = ["letters"] - exclude = ['letters'] + +# For use in Case Study form +class EpistolaryEventInline(admin.StackedInline): + model = models.CaseStudy.epistolary_events.through + extra = 0 + verbose_name = "epistolary event" + verbose_name_plural = "epistolary events" + + +@admin.register(models.CaseStudy) +class CaseStudyAdmin(admin.ModelAdmin): + fields = ["name"] + inlines = [EpistolaryEventInline] + extra = 0 + + +class EpistolaryEventCaseStudyInline(admin.StackedInline): + model = models.EpistolaryEvent.case_studies.through + extra = 0 + verbose_name_plural = "case studies" + verbose_name = "relationship between a case study and an epistolary event" + + +class EpistolaryEventLetterActionInline(admin.StackedInline): + model = models.EpistolaryEvent.letter_actions.through + extra = 0 + verbose_name_plural = "letter actions" + verbose_name = "relationship between a epistolary event and a letter action" + +@admin.register(models.EpistolaryEvent) +class EpistolaryEventAdmin(admin.ModelAdmin): + fields = ["name", "note"] + inlines = [ + EpistolaryEventCaseStudyInline, + EpistolaryEventLetterActionInline + ] class LetterMaterialAdmin(admin.StackedInline): model = models.LetterMaterial - fields = ['surface', 'certainty', 'note'] + fields = ["surface", "certainty", "note"] + +# For use in LetterForm +class LetterActionInline(admin.StackedInline): + model = models.LetterAction + inlines = [RoleAdmin, EpistolaryEventAdmin] + extra = 0 @admin.register(models.Letter) class LetterAdmin(admin.ModelAdmin): inlines = [ LetterMaterialAdmin, - EventLetterAdmin, ] diff --git a/backend/data/conftest.py b/backend/data/conftest.py index be3d6d78..7e6db610 100644 --- a/backend/data/conftest.py +++ b/backend/data/conftest.py @@ -1,23 +1,50 @@ import pytest - -from . import models +from data.models import ( + CaseStudy, + Letter, + EpistolaryEvent, + LetterAction, + LetterActionCategory, + Person, +) @pytest.fixture() def letter(db): - letter = models.Letter.objects.create() + letter = Letter.objects.create() return letter @pytest.fixture() -def epistolary_event(db, letter): - event = models.EpistolaryEvent.objects.create() +def person(db): + person = Person.objects.create() + return person + - models.EpistolaryEventCategory.objects.create( - value='write', - event=event +@pytest.fixture() +def letter_action(db, letter, person): + letter_action = LetterAction.objects.create() + letter_action.letters.add(letter) + letter_action.actors.add(person) + + LetterActionCategory.objects.create( + letter_action=letter_action, + value="write", ) - event.letters.add(letter) + return letter_action + + +@pytest.fixture() +def case_study(db): + case_study = CaseStudy.objects.create(name="Test Case Study") + return case_study + + +@pytest.fixture() +def epistolary_event(db, letter, case_study): + epistolary_event = EpistolaryEvent.objects.create( + name="Test Epistolary event", note="Test note", case_studies=[case_study] + ) - return event + return epistolary_event diff --git a/backend/data/migrations/0002_casestudy_letteraction_letteractioncategory_and_more.py b/backend/data/migrations/0002_casestudy_letteraction_letteractioncategory_and_more.py new file mode 100644 index 00000000..ecfaa67b --- /dev/null +++ b/backend/data/migrations/0002_casestudy_letteraction_letteractioncategory_and_more.py @@ -0,0 +1,123 @@ +# Generated by Django 4.2.7 on 2024-01-29 09:35 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('data', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='CaseStudy', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=256)), + ], + options={ + 'verbose_name': 'case study', + 'verbose_name_plural': 'case studies', + }, + ), + migrations.CreateModel( + name='LetterAction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + ), + migrations.CreateModel( + name='LetterActionCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('certainty', models.IntegerField(choices=[(0, 'uncertain'), (1, 'somewhat certain'), (2, 'certain')], default=2, help_text='How certain are you of this value?')), + ('note', models.TextField(blank=True, help_text='Additional notes')), + ('value', models.CharField(choices=[('write', 'writing'), ('transport', 'transporting'), ('deliver', 'delivering'), ('read', 'reading'), ('sign', 'signing'), ('eat', 'eating')], help_text='The type of event')), + ('letter_action', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='categories', to='data.letteraction')), + ], + options={ + 'verbose_name_plural': 'letter action categories', + }, + ), + migrations.CreateModel( + name='LetterEventDate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('certainty', models.IntegerField(choices=[(0, 'uncertain'), (1, 'somewhat certain'), (2, 'certain')], default=2, help_text='How certain are you of this value?')), + ('note', models.TextField(blank=True, help_text='Additional notes')), + ('year_lower', models.IntegerField(default=400, help_text='The earliest possible year for the letter action', validators=[django.core.validators.MinValueValidator(400), django.core.validators.MaxValueValidator(800)])), + ('year_upper', models.IntegerField(default=800, help_text='The latest possible year for the letter action', validators=[django.core.validators.MinValueValidator(400), django.core.validators.MaxValueValidator(800)])), + ('year_exact', models.IntegerField(blank=True, help_text='The exact year of the letter action (if known)', null=True, validators=[django.core.validators.MinValueValidator(400), django.core.validators.MaxValueValidator(800)])), + ('letter_action', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='date', to='data.letteraction')), + ], + options={ + 'abstract': False, + }, + ), + migrations.RemoveField( + model_name='epistolaryeventdate', + name='event', + ), + migrations.RemoveField( + model_name='epistolaryevent', + name='actors', + ), + migrations.RemoveField( + model_name='epistolaryevent', + name='letters', + ), + migrations.RemoveField( + model_name='role', + name='event', + ), + migrations.AddField( + model_name='epistolaryevent', + name='name', + field=models.CharField(default='unnamed event', max_length=256), + preserve_default=False, + ), + migrations.AddField( + model_name='epistolaryevent', + name='note', + field=models.TextField(blank=True, help_text='Additional notes that describe the event and what connects the letter actions it comprises.'), + ), + migrations.DeleteModel( + name='EpistolaryEventCategory', + ), + migrations.DeleteModel( + name='EpistolaryEventDate', + ), + migrations.AddField( + model_name='letteraction', + name='actors', + field=models.ManyToManyField(related_name='events', through='data.Role', to='data.person'), + ), + migrations.AddField( + model_name='letteraction', + name='epistolary_events', + field=models.ManyToManyField(help_text='epistolary events this letter action belongs to', related_name='letter_actions', to='data.epistolaryevent'), + ), + migrations.AddField( + model_name='letteraction', + name='letters', + field=models.ManyToManyField(help_text='letters involved in this event', related_name='events', to='data.letter'), + ), + migrations.AddField( + model_name='epistolaryevent', + name='case_studies', + field=models.ManyToManyField(help_text='case studies this event belongs to', related_name='epistolary_events', to='data.casestudy'), + ), + migrations.AddField( + model_name='role', + name='letter_action', + field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to='data.letteraction'), + preserve_default=False, + ), + migrations.AddConstraint( + model_name='letteractioncategory', + constraint=models.UniqueConstraint(models.F('value'), models.F('letter_action'), name='unique_categories_for_letter_action'), + ), + ] diff --git a/backend/data/models.py b/backend/data/models.py index 09a4d66b..0db1a635 100644 --- a/backend/data/models.py +++ b/backend/data/models.py @@ -1,12 +1,13 @@ from django.db import models from django.core.validators import MinValueValidator, MaxValueValidator + class Field(models.Model): certainty = models.IntegerField( choices=[ - (0, 'uncertain'), - (1, 'somewhat certain'), - (2, 'certain'), + (0, "uncertain"), + (1, "somewhat certain"), + (2, "certain"), ], default=2, help_text="How certain are you of this value?", @@ -24,16 +25,16 @@ class Meta: class Letter(models.Model): def __str__(self): - return f'letter #{self.id}' + return f"letter #{self.id}" class LetterMaterial(Field, models.Model): surface = models.CharField( choices=[ - ('parchment', 'parchment'), - ('papyrus', 'papyrus'), - ('other', 'other'), - ('unknown', 'unknown'), + ("parchment", "parchment"), + ("papyrus", "papyrus"), + ("other", "other"), + ("unknown", "unknown"), ], null=False, blank=False, @@ -46,9 +47,9 @@ class LetterMaterial(Field, models.Model): def __str__(self): if self.letter: - return f'material of {self.letter}' + return f"material of {self.letter}" else: - return f'material #{self.id}' + return f"material #{self.id}" class Person(models.Model): @@ -56,7 +57,7 @@ def __str__(self): if self.names.count(): return self.names.first().value else: - return f'Unknown person #{self.id}' + return f"Unknown person #{self.id}" class PersonName(Field, models.Model): @@ -69,7 +70,7 @@ class PersonName(Field, models.Model): to=Person, on_delete=models.CASCADE, null=False, - related_name='names', + related_name="names", unique=True, ) @@ -77,50 +78,106 @@ def __str__(self): return self.value +class CaseStudy(models.Model): + """ + A case study is an overarching collection of epistolary events, bound together by a common theme, e.g. `The Saga of St. Boniface` or `The Nun Rebellion of Poitiers`. + """ + + class Meta: + verbose_name = "case study" + verbose_name_plural = "case studies" + + name = models.CharField( + max_length=256, + null=False, + blank=False, + ) + + def __str__(self): + return self.name + + class EpistolaryEvent(models.Model): + """ + Epistolary events are groups of related letter actions that are connected in some way. + + For instance, a political campaign (epistolary event) may consist of the writing, transporting and reading of individual letters (letter actions). + """ + + name = models.CharField( + max_length=256, + null=False, + blank=False, + ) + + case_studies = models.ManyToManyField( + to=CaseStudy, + related_name="epistolary_events", + help_text="case studies this event belongs to", + ) + + note = models.TextField( + null=False, + blank=True, + help_text="Additional notes that describe the event and what connects the letter actions it comprises." + ) + + def __str__(self): + return f"{self.name}" + + +class LetterAction(models.Model): + """ + A letter action is an atomic action performed on a letter, e.g. writing, delivering, reading. + + These can be grouped into epistolary events. + """ + letters = models.ManyToManyField( to=Letter, - related_name='events', - help_text='letters involved in this event', + related_name="events", + help_text="letters involved in this event", ) actors = models.ManyToManyField( to=Person, - through='Role', - related_name='events', + through="Role", + related_name="events", + ) + + epistolary_events = models.ManyToManyField( + to=EpistolaryEvent, + related_name="letter_actions", + help_text="epistolary events this letter action belongs to", ) def __str__(self): categories = self.categories.all() - category_names = [ - category.get_value_display() - for category in categories - ] - category_desc = ', '.join(category_names) - letters = ', '.join(letter.__str__() for letter in self.letters.all()) - return f'{category_desc} of {letters}' - + category_names = [category.get_value_display() for category in categories] + category_desc = ", ".join(category_names) + letters = ", ".join(letter.__str__() for letter in self.letters.all()) + return f"{category_desc} of {letters}" -class EpistolaryEventCategory(Field, models.Model): +class LetterActionCategory(Field, models.Model): value = models.CharField( choices=[ - ('write', 'writing'), - ('transport', 'transporting'), - ('deliver', 'delivering'), - ('read', 'reading'), - ('sign', 'signing'), - ('eat', 'eating'), + ("write", "writing"), + ("transport", "transporting"), + ("deliver", "delivering"), + ("read", "reading"), + ("sign", "signing"), + ("eat", "eating"), ], null=False, blank=False, - help_text='The type of event' + help_text="The type of event", ) - event = models.ForeignKey( - to=EpistolaryEvent, + letter_action = models.ForeignKey( + to=LetterAction, on_delete=models.CASCADE, - related_name='categories', + related_name="categories", null=False, blank=False, ) @@ -128,17 +185,16 @@ class EpistolaryEventCategory(Field, models.Model): class Meta: constraints = [ models.UniqueConstraint( - 'value', - 'event', - name='unique_categories_for_event' + "value", "letter_action", name="unique_categories_for_letter_action" ) ] - verbose_name_plural = 'epistolary event categories' + verbose_name_plural = "letter action categories" def __str__(self): - return f'{self.event}: {self.get_value_display()}' + return f"{self.letter_action}: {self.get_value_display()}" + -class EpistolaryEventDate(Field, models.Model): +class LetterEventDate(Field, models.Model): MIN_YEAR = 400 MAX_YEAR = 800 @@ -148,7 +204,7 @@ class EpistolaryEventDate(Field, models.Model): MaxValueValidator(MAX_YEAR), ], default=MIN_YEAR, - help_text='The earliest possible year for the event' + help_text="The earliest possible year for the letter action", ) year_upper = models.IntegerField( @@ -157,7 +213,7 @@ class EpistolaryEventDate(Field, models.Model): MaxValueValidator(MAX_YEAR), ], default=MAX_YEAR, - help_text='The latest possible year for the event', + help_text="The latest possible year for the letter action", ) year_exact = models.IntegerField( @@ -167,13 +223,11 @@ class EpistolaryEventDate(Field, models.Model): MinValueValidator(MIN_YEAR), MaxValueValidator(MAX_YEAR), ], - help_text='The exact year of the event (if known)' + help_text="The exact year of the letter action (if known)", ) - event = models.OneToOneField( - to=EpistolaryEvent, - on_delete=models.CASCADE, - related_name='date' + letter_action = models.OneToOneField( + to=LetterAction, on_delete=models.CASCADE, related_name="date" ) def clean(self): @@ -181,45 +235,52 @@ def clean(self): self.year_lower = self.year_exact self.year_upper = self.year_exact + def __str__(self): + date = self.year_exact or f"{self.year_lower}–{self.year_upper}" + return f"{self.letter_action} in {date}" + class Role(Field, models.Model): + """ + Describes the involvement of a person in a letter action. + """ person = models.ForeignKey( to=Person, on_delete=models.CASCADE, null=False, ) - event = models.ForeignKey( - to=EpistolaryEvent, + letter_action = models.ForeignKey( + to=LetterAction, on_delete=models.CASCADE, null=False, ) present = models.BooleanField( null=False, default=True, - help_text='Whether this person was physically present', + help_text="Whether this person was physically present", ) role = models.CharField( choices=[ - ('author', 'Author'), - ('scribe', 'Scribe'), - ('reader', 'Reader'), - ('witness', 'Witness'), - ('messenger', 'Messenger'), - ('recipient', 'Recipient'), - ('intended_recipient', 'Intended recipient'), - ('audience', 'Audience'), - ('intended_audience', 'Intended audience'), - ('other', 'Other'), + ("author", "Author"), + ("scribe", "Scribe"), + ("reader", "Reader"), + ("witness", "Witness"), + ("messenger", "Messenger"), + ("recipient", "Recipient"), + ("intended_recipient", "Intended recipient"), + ("audience", "Audience"), + ("intended_audience", "Intended audience"), + ("other", "Other"), ], null=False, blank=False, - help_text='Role of this person in the event' + help_text="Role of this person in the event", ) description = models.TextField( null=False, blank=True, - help_text='Longer description of this person\'s involvement', + help_text="Longer description of this person's involvement", ) def __str__(self): - return f'role of {self.person} in {self.event}' + return f"role of {self.person} in {self.letter_action}" diff --git a/backend/data/tests/test_models.py b/backend/data/tests/test_models.py index cfe9e8f7..98d27fe2 100644 --- a/backend/data/tests/test_models.py +++ b/backend/data/tests/test_models.py @@ -1,6 +1,6 @@ -def test_event_name(letter, epistolary_event): - letter_str = str(letter) +def test_letter_action_name(letter, letter_action): + action_str = str(letter_action) - assert str(epistolary_event) == f'writing of {letter_str}' + assert str(action_str) == f'writing of {str(letter)}' diff --git a/package.json b/package.json index ae8eccbd..f90969ec 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Lettercraft & Epistolary Performance in Medieval Europe", "author": "UU Centre for Digital Humanities - Research Software Lab", "license": "BSD-3-Clause", - "repository": "github:UUDigitalHumanitieslab/lettercraft", + "repository": "github:CentreForDigitalHumanities/lettercraft", "private": true, "scripts": { "front": "cd frontend && ",