From bd5dd2f9490df8a9c92c025830697b70eeb4826b Mon Sep 17 00:00:00 2001 From: Nick Sweeting <git@sweeting.me> Date: Thu, 12 Dec 2024 21:34:48 -0800 Subject: [PATCH] clearer core models separation of concerns using new basemodels --- archivebox/core/models.py | 691 +++++++++++++++++++++++++++++++------- 1 file changed, 575 insertions(+), 116 deletions(-) diff --git a/archivebox/core/models.py b/archivebox/core/models.py index fe984bb2..d3a3d63a 100644 --- a/archivebox/core/models.py +++ b/archivebox/core/models.py @@ -11,6 +11,7 @@ from pathlib import Path from django.db import models from django.db.models import QuerySet +from django.core.validators import MinValueValidator, MaxValueValidator from django.utils.functional import cached_property from django.utils.text import slugify from django.utils import timezone @@ -30,18 +31,23 @@ from archivebox.misc.hashing import get_dir_info from archivebox.index.schema import Link from archivebox.index.html import snapshot_icons from archivebox.extractors import ARCHIVE_METHODS_INDEXING_PRECEDENCE -from archivebox.base_models.models import ABIDModel, ABIDField, AutoDateTimeField, ModelWithOutputDir, ModelWithConfig - +from archivebox.base_models.models import ( + ABIDModel, ABIDField, AutoDateTimeField, get_or_create_system_user_pk, + ModelWithReadOnlyFields, ModelWithSerializers, ModelWithUUID, ModelWithKVTags # ModelWithStateMachine + ModelWithOutputDir, ModelWithConfig, ModelWithNotes, ModelWithHealthStats +) from workers.models import ModelWithStateMachine from workers.tasks import bg_archive_snapshot -from crawls.models import Crawl +from tags.models import KVTag # from machine.models import Machine, NetworkInterface -class Tag(ABIDModel): +class Tag(ModelWithReadOnlyFields, ModelWithSerializers, ModelWithUUID, ABIDModel): """ - Loosely based on django-taggit model + ABID base. + Old tag model, loosely based on django-taggit model + ABID base. + + Being phazed out in favor of archivebox.tags.models.ATag """ abid_prefix = 'tag_' abid_ts_src = 'self.created_at' @@ -49,11 +55,13 @@ class Tag(ABIDModel): abid_subtype_src = '"03"' abid_rand_src = 'self.id' abid_drift_allowed = True + + read_only_fields = ('id', 'abid', 'created_at', 'created_by', 'slug') id = models.UUIDField(primary_key=True, default=None, null=False, editable=False, unique=True, verbose_name='ID') abid = ABIDField(prefix=abid_prefix) - created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=None, null=False, related_name='tag_set') + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=get_or_create_system_user_pk, null=False, related_name='tag_set') created_at = AutoDateTimeField(default=None, null=False, db_index=True) modified_at = models.DateTimeField(auto_now=True) @@ -125,15 +133,400 @@ class SnapshotTag(models.Model): unique_together = [('snapshot', 'tag')] -# class CrawlTag(models.Model): -# id = models.AutoField(primary_key=True) +class Seed(ModelWithReadOnlyFields, ModelWithSerializers, ModelWithUUID, ModelWithKVTags, ABIDModel, ModelWithOutputDir, ModelWithConfig, ModelWithNotes, ModelWithHealthStats): + """ + A fountain that produces URLs (+metadata) each time it's queried e.g. + - file:///data/sources/2024-01-02_11-57-51__cli_add.txt + - file:///data/sources/2024-01-02_11-57-51__web_ui_add.txt + - file:///Users/squash/Library/Application Support/Google/Chrome/Default/Bookmarks + - https://getpocket.com/user/nikisweeting/feed + - https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml + - ... + Each query of a Seed can produce the same list of URLs, or a different list each time. + The list of URLs it returns is used to create a new Crawl and seed it with new pending Snapshots. + + When a crawl is created, a root_snapshot is initially created with a URI set to the Seed URI. + The seed's preferred extractor is executed on that URI, which produces an ArchiveResult containing outlinks. + The outlinks then get turned into new pending Snapshots under the same crawl, + and the cycle repeats until Crawl.max_depth. -# crawl = models.ForeignKey('Crawl', db_column='crawl_id', on_delete=models.CASCADE, to_field='id') -# tag = models.ForeignKey(Tag, db_column='tag_id', on_delete=models.CASCADE, to_field='id') + Each consumption of a Seed by an Extractor can produce new urls, as Seeds can point to + stateful remote services, files with contents that change, directories that have new files within, etc. + """ + + ### ModelWithReadOnlyFields: + read_only_fields = ('id', 'abid', 'created_at', 'created_by', 'uri') + + ### Immutable fields + id = models.UUIDField(primary_key=True, default=None, null=False, editable=False, unique=True, verbose_name='ID') + abid = ABIDField(prefix=abid_prefix) + created_at = AutoDateTimeField(default=None, null=False, db_index=True) # unique source location where URLs will be loaded from + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=get_or_create_system_user_pk, null=False) + + ### Mutable fields: + extractor = models.CharField(default='auto', max_length=32, help_text='The parser / extractor to use to load URLs from this source (default: auto)') + tags_str = models.CharField(max_length=255, null=False, blank=True, default='', help_text='An optional comma-separated list of tags to attach to any URLs that come from this source') + label = models.CharField(max_length=255, null=False, blank=True, default='', help_text='A human-readable label for this seed') + modified_at = models.DateTimeField(auto_now=True) -# class Meta: -# db_table = 'core_crawl_tags' -# unique_together = [('crawl', 'tag')] + ### ModelWithConfig: + config = models.JSONField(default=dict, help_text='An optional JSON object containing extra config to put in scope when loading URLs from this source') + + ### ModelWithOutputDir: + output_dir = models.CharField(max_length=255, null=False, blank=True, default='', help_text='The directory to store the output of this seed') + + ### ModelWithNotes: + notes = models.TextField(blank=True, null=False, default='', help_text='Any extra notes this seed should have') + + ### ModelWithKVTags: + tag_set = GenericRelation( + KVTag, + related_query_name="seed", + content_type_field="obj_type", + object_id_field="obj_id", + order_by=('name',), + ) + + ### ABIDModel: + abid_prefix = 'src_' + abid_ts_src = 'self.created_at' + abid_uri_src = 'self.uri' + abid_subtype_src = 'self.extractor' + abid_rand_src = 'self.id' + abid_drift_allowed = True + + ### Managers: + crawl_set: models.Manager['Crawl'] + + class Meta: + verbose_name = 'Seed' + verbose_name_plural = 'Seeds' + + unique_together = (('created_by', 'uri', 'extractor'),('created_by', 'label')) + + + @classmethod + def from_file(cls, source_file: Path, label: str='', parser: str='auto', tag: str='', created_by: int|None=None, config: dict|None=None): + source_path = str(source_file.resolve()).replace(str(CONSTANTS.DATA_DIR), '/data') + + seed, _ = cls.objects.get_or_create( + label=label or source_file.name, + uri=f'file://{source_path}', + created_by_id=getattr(created_by, 'pk', created_by) or get_or_create_system_user_pk(), + extractor=parser, + tags_str=tag, + config=config or {}, + ) + seed.save() + return seed + + @property + def source_type(self): + # e.g. http/https:// + # file:// + # pocketapi:// + # s3:// + # etc.. + return self.uri.split('://', 1)[0].lower() + + @property + def api_url(self) -> str: + # /api/v1/core/seed/{uulid} + return reverse_lazy('api-1:get_seed', args=[self.abid]) # + f'?api_key={get_or_create_api_token(request.user)}' + + @property + def api_docs_url(self) -> str: + return '/api/v1/docs#/Core%20Models/api_v1_core_get_seed' + + @property + def scheduled_crawl_set(self) -> QuerySet['CrawlSchedule']: + from crawls.models import CrawlSchedule + return CrawlSchedule.objects.filter(template__seed_id=self.pk) + + @property + def snapshot_set(self) -> QuerySet['Snapshot']: + from core.models import Snapshot + + crawl_ids = self.crawl_set.values_list('pk', flat=True) + return Snapshot.objects.filter(crawl_id__in=crawl_ids) + + + + +class CrawlSchedule(ModelWithReadOnlyFields, ModelWithSerializers, ModelWithUUID, ModelWithKVTags, ABIDModel, ModelWithNotes, ModelWithHealthStats): + """ + A record for a job that should run repeatedly on a given schedule. + + It pulls from a given Seed and creates a new Crawl for each scheduled run. + The new Crawl will inherit all the properties of the crawl_template Crawl. + """ + ### ModelWithReadOnlyFields: + read_only_fields = ('id', 'abid', 'created_at', 'created_by', 'template_id') + + ### Immutable fields: + id = models.UUIDField(primary_key=True, default=None, null=False, editable=False, unique=True, verbose_name='ID') + abid = ABIDField(prefix=abid_prefix) + created_at = AutoDateTimeField(default=None, null=False, db_index=True) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=get_or_create_system_user_pk, null=False) + template: 'Crawl' = models.ForeignKey('Crawl', on_delete=models.CASCADE, null=False, blank=False, help_text='The base crawl that each new scheduled job should copy as a template') # type: ignore + + ### Mutable fields + schedule = models.CharField(max_length=64, blank=False, null=False, help_text='The schedule to run this crawl on in CRON syntax e.g. 0 0 * * * (see https://crontab.guru/)') + is_enabled = models.BooleanField(default=True) + label = models.CharField(max_length=64, blank=True, null=False, default='', help_text='A human-readable label for this scheduled crawl') + notes = models.TextField(blank=True, null=False, default='', help_text='Any extra notes this crawl should have') + modified_at = models.DateTimeField(auto_now=True) + + ### ModelWithKVTags: + tag_set = GenericRelation( + KVTag, + related_query_name="crawlschedule", + content_type_field="obj_type", + object_id_field="obj_id", + order_by=('name',), + ) + + ### ABIDModel: + abid_prefix = 'cws_' + abid_ts_src = 'self.created_at' + abid_uri_src = 'self.template.seed.uri' + abid_subtype_src = 'self.template.persona' + abid_rand_src = 'self.id' + abid_drift_allowed = True + + ### Managers: + crawl_set: models.Manager['Crawl'] + + class Meta(TypedModelMeta): + verbose_name = 'Scheduled Crawl' + verbose_name_plural = 'Scheduled Crawls' + + def __str__(self) -> str: + uri = (self.template and self.template.seed and self.template.seed.uri) or '<no url set>' + crawl_label = self.label or (self.template and self.template.seed and self.template.seed.label) or 'Untitled Crawl' + if self.id and self.template: + return f'[{self.ABID}] {uri[:64]} @ {self.schedule} (Scheduled {crawl_label})' + return f'[{self.abid_prefix}****not*saved*yet****] {uri[:64]} @ {self.schedule} (Scheduled {crawl_label})' + + @property + def api_url(self) -> str: + # /api/v1/core/crawlschedule/{uulid} + return reverse_lazy('api-1:get_any', args=[self.abid]) # + f'?api_key={get_or_create_api_token(request.user)}' + + @property + def api_docs_url(self) -> str: + return '/api/v1/docs#/Core%20Models/api_v1_core_get_any' + + def save(self, *args, **kwargs): + self.label = self.label or self.template.seed.label or self.template.seed.uri + super().save(*args, **kwargs) + + # make sure the template crawl points to this schedule as its schedule + self.template.schedule = self + self.template.save() + + @property + def snapshot_set(self) -> QuerySet['Snapshot']: + from core.models import Snapshot + + crawl_ids = self.crawl_set.values_list('pk', flat=True) + return Snapshot.objects.filter(crawl_id__in=crawl_ids) + + +class CrawlManager(models.Manager): + pass + +class CrawlQuerySet(models.QuerySet): + """ + Enhanced QuerySet for Crawl that adds some useful methods. + + To get all the snapshots for a given set of Crawls: + Crawl.objects.filter(seed__uri='https://example.com/some/rss.xml').snapshots() -> QuerySet[Snapshot] + + To get all the archiveresults for a given set of Crawls: + Crawl.objects.filter(seed__uri='https://example.com/some/rss.xml').archiveresults() -> QuerySet[ArchiveResult] + + To export the list of Crawls as a CSV or JSON: + Crawl.objects.filter(seed__uri='https://example.com/some/rss.xml').export_as_csv() -> str + Crawl.objects.filter(seed__uri='https://example.com/some/rss.xml').export_as_json() -> str + """ + def snapshots(self, **filter_kwargs) -> QuerySet['Snapshot']: + return Snapshot.objects.filter(crawl_id__in=self.values_list('pk', flat=True), **filter_kwargs) + + def archiveresults(self) -> QuerySet['ArchiveResult']: + return ArchiveResult.objects.filter(snapshot__crawl_id__in=self.values_list('pk', flat=True)) + + def as_csv_str(self, keys: Iterable[str]=()) -> str: + return '\n'.join( + row.as_csv(keys=keys) + for row in self.all() + ) + + def as_jsonl_str(self, keys: Iterable[str]=()) -> str: + return '\n'.join([ + row.as_jsonl_row(keys=keys) + for row in self.all() + ]) + + + +class Crawl(ModelWithReadOnlyFields, ModelWithSerializers, ModelWithUUID, ModelWithKVTags, ABIDModel, ModelWithOutputDir, ModelWithConfig, ModelWithHealthStats, ModelWithStateMachine): + """ + A single session of URLs to archive starting from a given Seed and expanding outwards. An "archiving session" so to speak. + + A new Crawl should be created for each loading from a Seed (because it can produce a different set of URLs every time its loaded). + E.g. every scheduled import from an RSS feed should create a new Crawl, and more loadings from the same seed each create a new Crawl + + Every "Add" task triggered from the Web UI, CLI, or Scheduled Crawl should create a new Crawl with the seed set to a + file URI e.g. file:///sources/<date>_{ui,cli}_add.txt containing the user's input. + """ + + ### ModelWithReadOnlyFields: + read_only_fields = ('id', 'abid', 'created_at', 'created_by', 'seed') + + ### Immutable fields: + id = models.UUIDField(primary_key=True, default=None, null=False, editable=False, unique=True, verbose_name='ID') + abid = ABIDField(prefix=abid_prefix) + created_at = AutoDateTimeField(default=None, null=False, db_index=True) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=get_or_create_system_user_pk, null=False) + seed = models.ForeignKey(Seed, on_delete=models.PROTECT, related_name='crawl_set', null=False, blank=False) + + ### Mutable fields: + urls = models.TextField(blank=True, null=False, default='', help_text='The log of URLs discovered in this crawl, one per line, should be 1:1 with snapshot_set') + config = models.JSONField(default=dict) + max_depth = models.PositiveSmallIntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(4)]) + tags_str = models.CharField(max_length=1024, blank=True, null=False, default='') + persona_id = models.UUIDField(null=True, blank=True) # TODO: replace with self.persona = models.ForeignKey(Persona, on_delete=models.SET_NULL, null=True, blank=True, editable=True) + label = models.CharField(max_length=64, blank=True, null=False, default='', help_text='A human-readable label for this crawl') + notes = models.TextField(blank=True, null=False, default='', help_text='Any extra notes this crawl should have') + schedule = models.ForeignKey(CrawlSchedule, on_delete=models.SET_NULL, null=True, blank=True, editable=True) + modified_at = models.DateTimeField(auto_now=True) + + ### ModelWithKVTags: + tag_set = GenericRelation( + KVTag, + related_query_name="crawl", + content_type_field="obj_type", + object_id_field="obj_id", + order_by=('name',), + ) + + ### ModelWithStateMachine: + state_machine_name = 'crawls.statemachines.CrawlMachine' + retry_at_field_name = 'retry_at' + state_field_name = 'status' + StatusChoices = ModelWithStateMachine.StatusChoices + active_state = StatusChoices.STARTED + + status = ModelWithStateMachine.StatusField(choices=StatusChoices, default=StatusChoices.QUEUED) + retry_at = ModelWithStateMachine.RetryAtField(default=timezone.now) + + ### ABIDModel: + abid_prefix = 'cwl_' + abid_ts_src = 'self.created_at' + abid_uri_src = 'self.seed.uri' + abid_subtype_src = 'self.persona' + abid_rand_src = 'self.id' + abid_drift_allowed = True + + ### Managers: + snapshot_set: models.Manager['Snapshot'] + + # @property + # def persona(self) -> Persona: + # # TODO: replace with self.persona = models.ForeignKey(Persona, on_delete=models.SET_NULL, null=True, blank=True, editable=True) + # return self.persona_id + + + class Meta(TypedModelMeta): + verbose_name = 'Crawl' + verbose_name_plural = 'Crawls' + + def __str__(self): + url = (self.seed and self.seed.uri) or '<no url set>' + parser = (self.seed and self.seed.extractor) or 'auto' + created_at = self.created_at.strftime("%Y-%m-%d %H:%M") if self.created_at else '<no timestamp set>' + if self.id and self.seed: + return f'[{self.ABID}] {url[:64]} ({parser}) @ {created_at} ({self.label or "Untitled Crawl"})' + return f'[{self.abid_prefix}****not*saved*yet****] {url[:64]} ({parser}) @ {created_at} ({self.label or "Untitled Crawl"})' + + @classmethod + def from_seed(cls, seed: Seed, max_depth: int=0, persona: str='Default', tags_str: str='', config: dict|None=None, created_by: int|None=None): + crawl, _ = cls.objects.get_or_create( + seed=seed, + max_depth=max_depth, + tags_str=tags_str or seed.tags_str, + persona=persona or seed.config.get('DEFAULT_PERSONA') or 'Default', + config=seed.config or config or {}, + created_by_id=getattr(created_by, 'pk', created_by) or seed.created_by_id, + ) + crawl.save() + return crawl + + @property + def template(self): + """If this crawl was created under a ScheduledCrawl, returns the original template Crawl it was based off""" + if not self.schedule: + return None + return self.schedule.template + + @property + def api_url(self) -> str: + # /api/v1/core/crawl/{uulid} + # TODO: implement get_crawl + return reverse_lazy('api-1:get_crawl', args=[self.abid]) # + f'?api_key={get_or_create_api_token(request.user)}' + + @property + def api_docs_url(self) -> str: + return '/api/v1/docs#/Core%20Models/api_v1_core_get_crawl' + + def pending_snapshots(self) -> QuerySet['Snapshot']: + return self.snapshot_set.filter(retry_at__isnull=False) + + def pending_archiveresults(self) -> QuerySet['ArchiveResult']: + from core.models import ArchiveResult + + snapshot_ids = self.snapshot_set.values_list('id', flat=True) + pending_archiveresults = ArchiveResult.objects.filter(snapshot_id__in=snapshot_ids, retry_at__isnull=False) + return pending_archiveresults + + def create_root_snapshot(self) -> 'Snapshot': + print(f'Crawl[{self.ABID}].create_root_snapshot()') + from core.models import Snapshot + + try: + return Snapshot.objects.get(crawl=self, url=self.seed.uri) + except Snapshot.DoesNotExist: + pass + + root_snapshot, _ = Snapshot.objects.update_or_create( + crawl=self, + url=self.seed.uri, + defaults={ + 'status': Snapshot.INITIAL_STATE, + 'retry_at': timezone.now(), + 'timestamp': str(timezone.now().timestamp()), + # 'config': self.seed.config, + }, + ) + root_snapshot.save() + return root_snapshot + + +class Outlink(ModelWithReadOnlyFields, ModelWithSerializers, ModelWithUUID, ModelWithKVTags): + """A record of a link found on a page, pointing to another page.""" + read_only_fields = ('id', 'src', 'dst', 'crawl', 'via') + + id = models.UUIDField(primary_key=True, default=None, null=False, editable=False, unique=True, verbose_name='ID') + + src = models.URLField() # parent page where the outlink/href was found e.g. https://example.com/downloads + dst = models.URLField() # remote location the child outlink/href points to e.g. https://example.com/downloads/some_file.pdf + + crawl = models.ForeignKey(Crawl, on_delete=models.CASCADE, null=False, blank=False, related_name='outlink_set') + via = models.ForeignKey('core.ArchiveResult', on_delete=models.SET_NULL, null=True, blank=True, related_name='outlink_set') + + class Meta: + unique_together = (('src', 'dst', 'via'),) @@ -158,86 +551,83 @@ class SnapshotManager(models.Manager): # .annotate(archiveresult_count=models.Count('archiveresult')).distinct() ) -class Snapshot(ModelWithOutputDir, ModelWithConfig, ModelWithStateMachine, ABIDModel): + +class Snapshot( + ModelWithReadOnlyFields, + ModelWithSerializers, + ModelWithUUID, + ModelWithKVTags, + ABIDModel, + ModelWithOutputDir, + ModelWithConfig, + ModelWithNotes, + ModelWithHealthStats, + ModelWithStateMachine, +): + + ### ModelWithSerializers + # cls.from_dict() -> Self + # self.as_json() -> dict[str, Any] + # self.as_jsonl_row() -> str + # self.as_csv_row() -> str + # self.as_html_icon(), .as_html_embed(), .as_html_row(), ... + + ### ModelWithReadOnlyFields + read_only_fields = ('id', 'abid', 'created_at', 'created_by_id', 'url', 'timestamp', 'bookmarked_at', 'crawl_id') + + ### Immutable fields: + id = models.UUIDField(primary_key=True, default=None, null=False, editable=False, unique=True, verbose_name='ID') + abid = ABIDField(prefix=abid_prefix) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=None, null=False, related_name='snapshot_set', db_index=True) + created_at = AutoDateTimeField(default=None, null=False, db_index=True) # loaded from self._init_timestamp + + url = models.URLField(unique=True, db_index=True) + timestamp = models.CharField(max_length=32, unique=True, db_index=True, editable=False, validators=[validate_timestamp]) + bookmarked_at = AutoDateTimeField(default=None, null=False, editable=True, db_index=True) + crawl: Crawl = models.ForeignKey(Crawl, on_delete=models.CASCADE, default=None, null=True, blank=True, related_name='snapshot_set', db_index=True) # type: ignore + + ### Mutable fields: + title = models.CharField(max_length=512, null=True, blank=True, db_index=True) + downloaded_at = models.DateTimeField(default=None, null=True, editable=False, db_index=True, blank=True) + modified_at = models.DateTimeField(auto_now=True) + + ### ModelWithStateMachine + retry_at = ModelWithStateMachine.RetryAtField(default=timezone.now) + status = ModelWithStateMachine.StatusField(choices=StatusChoices, default=StatusChoices.QUEUED) + + ### ModelWithConfig + config = models.JSONField(default=dict, null=False, blank=False, editable=True) + + ### ModelWithNotes + notes = models.TextField(blank=True, null=False, default='', help_text='Any extra notes this snapshot should have') + + ### ModelWithOutputDir + output_dir = models.FilePathField(path=CONSTANTS.ARCHIVE_DIR, recursive=True, match='.*', default=None, null=True, blank=True, editable=True) + # self.output_dir_parent -> str 'archive/snapshots/<YYYY-MM-DD>/<example.com>' + # self.output_dir_name -> '<abid>' + # self.output_dir_str -> 'archive/snapshots/<YYYY-MM-DD>/<example.com>/<abid>' + # self.OUTPUT_DIR -> Path('/data/archive/snapshots/<YYYY-MM-DD>/<example.com>/<abid>') + # self.save(): creates OUTPUT_DIR, writes index.json, writes indexes + + # old-style tags (dedicated ManyToMany Tag model above): + tags = models.ManyToManyField(Tag, blank=True, through=SnapshotTag, related_name='snapshot_set', through_fields=('snapshot', 'tag')) + + # new-style tags (new key-value tags defined by tags.models.KVTag & ModelWithKVTags): + kvtag_set = tag_set = GenericRelation( + KVTag, + related_query_name="snapshot", + content_type_field="obj_type", + object_id_field="obj_id", + order_by=('created_at',), + ) + + ### ABIDModel abid_prefix = 'snp_' abid_ts_src = 'self.created_at' abid_uri_src = 'self.url' abid_subtype_src = '"01"' abid_rand_src = 'self.id' abid_drift_allowed = True - - state_machine_name = 'core.statemachines.SnapshotMachine' - state_field_name = 'status' - retry_at_field_name = 'retry_at' - StatusChoices = ModelWithStateMachine.StatusChoices - active_state = StatusChoices.STARTED - - id = models.UUIDField(primary_key=True, default=None, null=False, editable=False, unique=True, verbose_name='ID') - abid = ABIDField(prefix=abid_prefix) - - created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=None, null=False, related_name='snapshot_set', db_index=True) - created_at = AutoDateTimeField(default=None, null=False, db_index=True) # loaded from self._init_timestamp - modified_at = models.DateTimeField(auto_now=True) - - retry_at = ModelWithStateMachine.RetryAtField(default=timezone.now) - status = ModelWithStateMachine.StatusField(choices=StatusChoices, default=StatusChoices.QUEUED) - notes = models.TextField(blank=True, null=False, default='', help_text='Any extra notes this snapshot should have') - - bookmarked_at = AutoDateTimeField(default=None, null=False, editable=True, db_index=True) - downloaded_at = models.DateTimeField(default=None, null=True, editable=False, db_index=True, blank=True) - - crawl: Crawl = models.ForeignKey(Crawl, on_delete=models.CASCADE, default=None, null=True, blank=True, related_name='snapshot_set', db_index=True) # type: ignore - - url = models.URLField(unique=True, db_index=True) - timestamp = models.CharField(max_length=32, unique=True, db_index=True, editable=False, validators=[validate_timestamp]) - tags = models.ManyToManyField(Tag, blank=True, through=SnapshotTag, related_name='snapshot_set', through_fields=('snapshot', 'tag')) - title = models.CharField(max_length=512, null=True, blank=True, db_index=True) - - - keys = ('url', 'timestamp', 'title', 'tags', 'downloaded_at', 'created_at', 'status', 'retry_at', 'abid', 'id') - - archiveresult_set: models.Manager['ArchiveResult'] - - objects = SnapshotManager() - - ### Inherited from ModelWithStateMachine ################################# - # StatusChoices = ModelWithStateMachine.StatusChoices - # - # status = ModelWithStateMachine.StatusField(choices=StatusChoices, default=StatusChoices.QUEUED) - # retry_at = ModelWithStateMachine.RetryAtField(default=timezone.now) - # - # state_machine_name = 'core.statemachines.SnapshotMachine' - # state_field_name = 'status' - # retry_at_field_name = 'retry_at' - # active_state = StatusChoices.STARTED - ######################################################################## - - ### Inherited from ModelWithConfig ####################################### - # config = models.JSONField(default=dict, null=False, blank=False, editable=True) - ######################################################################## - - ### Inherited from ModelWithOutputDir: - # output_dir = models.FilePathField(path=CONSTANTS.ARCHIVE_DIR, recursive=True, match='.*', default=None, null=True, blank=True, editable=True) - - # self.save(): creates OUTPUT_DIR, writes index.json, writes indexes - # self.output_dir_parent -> str 'archive/snapshots/<YYYY-MM-DD>/<example.com>' - # self.output_dir_name -> '<abid>' - # self.output_dir_str -> 'archive/snapshots/<YYYY-MM-DD>/<example.com>/<abid>' - # self.OUTPUT_DIR -> Path('/data/archive/snapshots/<YYYY-MM-DD>/<example.com>/<abid>') - - ### Inherited from ABIDModel: - # id = models.UUIDField(primary_key=True, default=None, null=False, editable=False, unique=True, verbose_name='ID') - # abid = ABIDField(prefix=abid_prefix) - # created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=None, null=False, related_name='snapshot_set', db_index=True) - # created_at = AutoDateTimeField(default=None, null=False, db_index=True) # loaded from self._init_timestamp - # modified_at = models.DateTimeField(auto_now=True) - - # abid_prefix = 'snp_' - # abid_ts_src = 'self.created_at' - # abid_uri_src = 'self.url' - # abid_subtype_src = '"01"' - # abid_rand_src = 'self.id' - # abid_drift_allowed = True # self.clean() -> sets self._timestamp # self.save() -> issues new ABID if creating new, otherwise uses existing ABID # self.ABID -> ABID @@ -246,9 +636,18 @@ class Snapshot(ModelWithOutputDir, ModelWithConfig, ModelWithStateMachine, ABIDM # self.admin_change_url -> '/admin/core/snapshot/{pk}/change/' # self.get_absolute_url() -> '/{self.archive_path}' # self.update_for_workers() -> bool - # self.as_json() -> dict[str, Any] - + ### ModelWithStateMachine + state_machine_name = 'core.statemachines.SnapshotMachine' + state_field_name = 'status' + retry_at_field_name = 'retry_at' + StatusChoices = ModelWithStateMachine.StatusChoices + active_state = StatusChoices.STARTED + + ### Relations & Managers + objects = SnapshotManager() + archiveresult_set: models.Manager['ArchiveResult'] + def save(self, *args, **kwargs): print(f'Snapshot[{self.ABID}].save()') if self.pk: @@ -292,16 +691,15 @@ class Snapshot(ModelWithOutputDir, ModelWithConfig, ModelWithStateMachine, ABIDM return repr(self) @classmethod - def from_json(cls, info: dict): - info = {k: v for k, v in info.items() if k in cls.keys} - return cls(**info) + def from_json(cls, fields: dict[str, Any]) -> Self: + # print('LEGACY from_json()') + return cls.from_dict(fields) - def as_json(self, *args) -> dict: - args = args or self.keys - return { - key: getattr(self, key) if key != 'tags' else self.tags_str(nocache=False) - for key in args - } + def as_json(self, *args, **kwargs) -> dict: + json_dict = super().as_json(*args, **kwargs) + if 'tags' in json_dict: + json_dict['tags'] = self.tags_str(nocache=False) + return json_dict def as_link(self) -> Link: return Link.from_json(self.as_json()) @@ -620,7 +1018,12 @@ class ArchiveResultManager(models.Manager): ).order_by('indexing_precedence') return qs -class ArchiveResult(ModelWithConfig, ModelWithOutputDir, ModelWithStateMachine, ABIDModel): + +class ArchiveResult( + ModelWithReadOnlyFields, ModelWithSerializers, ModelWithUUID, ModelWithKVTags, ABIDModel, + ModelWithOutputDir, ModelWithConfig, ModelWithNotes, ModelWithHealthStats, ModelWithStateMachine +): + ### ABIDModel abid_prefix = 'res_' abid_ts_src = 'self.snapshot.created_at' abid_uri_src = 'self.snapshot.url' @@ -628,6 +1031,7 @@ class ArchiveResult(ModelWithConfig, ModelWithOutputDir, ModelWithStateMachine, abid_rand_src = 'self.id' abid_drift_allowed = True + ### ModelWithStateMachine class StatusChoices(models.TextChoices): QUEUED = 'queued', 'Queued' # pending, initial STARTED = 'started', 'Started' # active @@ -658,33 +1062,48 @@ class ArchiveResult(ModelWithConfig, ModelWithOutputDir, ModelWithStateMachine, ('title', 'title'), ('wget', 'wget'), ) + + ### ModelWithReadOnlyFields + read_only_fields = ('id', 'abid', 'created_at', 'created_by', 'snapshot', 'extractor', 'pwd') - + ### Immutable fields: id = models.UUIDField(primary_key=True, default=None, null=False, editable=False, unique=True, verbose_name='ID') abid = ABIDField(prefix=abid_prefix) created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=None, null=False, related_name='archiveresult_set', db_index=True) created_at = AutoDateTimeField(default=None, null=False, db_index=True) - modified_at = models.DateTimeField(auto_now=True) - status = ModelWithStateMachine.StatusField(choices=StatusChoices.choices, default=StatusChoices.QUEUED) - retry_at = ModelWithStateMachine.RetryAtField(default=timezone.now) - snapshot: Snapshot = models.ForeignKey(Snapshot, on_delete=models.CASCADE) # type: ignore - extractor = models.CharField(choices=EXTRACTOR_CHOICES, max_length=32, blank=False, null=False, db_index=True) - cmd = models.JSONField(default=None, null=True, blank=True) pwd = models.CharField(max_length=256, default=None, null=True, blank=True) + + + ### Mutable fields: + cmd = models.JSONField(default=None, null=True, blank=True) + modified_at = models.DateTimeField(auto_now=True) cmd_version = models.CharField(max_length=128, default=None, null=True, blank=True) output = models.CharField(max_length=1024, default=None, null=True, blank=True) start_ts = models.DateTimeField(default=None, null=True, blank=True) end_ts = models.DateTimeField(default=None, null=True, blank=True) + + ### ModelWithStateMachine + status = ModelWithStateMachine.StatusField(choices=StatusChoices.choices, default=StatusChoices.QUEUED) + retry_at = ModelWithStateMachine.RetryAtField(default=timezone.now) + ### ModelWithNotes notes = models.TextField(blank=True, null=False, default='', help_text='Any extra notes this ArchiveResult should have') - # the network interface that was used to download this result + ### ModelWithHealthStats + # ... + + ### ModelWithKVTags + # tag_set = GenericRelation(KVTag, related_query_name='archiveresult') + + ### ModelWithOutputDir + output_dir = models.CharField(max_length=256, default=None, null=True, blank=True) + # machine = models.ForeignKey(Machine, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='Machine Used') - # network = models.ForeignKey(NetworkInterface, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='Network Interface Used') + iface = models.ForeignKey(NetworkInterface, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='Network Interface Used') objects = ArchiveResultManager() @@ -718,7 +1137,7 @@ class ArchiveResult(ModelWithConfig, ModelWithOutputDir, ModelWithStateMachine, super().save(*args, **kwargs) # DONT DO THIS: # self.snapshot.update_for_workers() # this should be done manually wherever its needed, not in here as a side-effect on save() - + # TODO: finish connecting machine.models # @cached_property @@ -777,13 +1196,6 @@ class ArchiveResult(ModelWithConfig, ModelWithOutputDir, ModelWithStateMachine, output_dir = Path(self.snapshot_dir) / self.extractor output_dir.mkdir(parents=True, exist_ok=True) return output_dir - - def as_json(self, *args) -> dict: - args = args or self.keys - return { - key: getattr(self, key) - for key in args - } def canonical_outputs(self) -> Dict[str, Optional[str]]: """Predict the expected output paths that should be present after archiving""" @@ -958,3 +1370,50 @@ class ArchiveResult(ModelWithConfig, ModelWithOutputDir, ModelWithStateMachine, # def symlink_index(self, create=True): # abs_result_dir = self.get_storage_dir(create=create) + + + + + + +# @abx.hookimpl.on_archiveresult_created +# def exec_archiveresult_extractor_effects(archiveresult): +# config = get_scope_config(...) + +# # abx.archivebox.writes.update_archiveresult_started(archiveresult, start_ts=timezone.now()) +# # abx.archivebox.events.on_archiveresult_updated(archiveresult) + +# # check if it should be skipped +# if not abx.archivebox.reads.get_archiveresult_should_run(archiveresult, config): +# abx.archivebox.writes.update_archiveresult_skipped(archiveresult, status='skipped') +# abx.archivebox.events.on_archiveresult_skipped(archiveresult, config) +# return + +# # run the extractor method and save the output back to the archiveresult +# try: +# output = abx.archivebox.effects.exec_archiveresult_extractor(archiveresult, config) +# abx.archivebox.writes.update_archiveresult_succeeded(archiveresult, output=output, error=None, end_ts=timezone.now()) +# except Exception as e: +# abx.archivebox.writes.update_archiveresult_failed(archiveresult, error=e, end_ts=timezone.now()) + +# # bump the modified time on the archiveresult and Snapshot +# abx.archivebox.events.on_archiveresult_updated(archiveresult) +# abx.archivebox.events.on_snapshot_updated(archiveresult.snapshot) + + +# @abx.hookimpl.reads.get_outlink_parents +# def get_outlink_parents(url, crawl_pk=None, config=None): +# scope = Q(dst=url) +# if crawl_pk: +# scope = scope | Q(via__snapshot__crawl_id=crawl_pk) + +# parent = list(Outlink.objects.filter(scope)) +# if not parent: +# # base case: we reached the top of the chain, no more parents left +# return [] + +# # recursive case: there is another parent above us, get its parents +# yield parent[0] +# yield from get_outlink_parents(parent[0].src, crawl_pk=crawl_pk, config=config) + +