repo_description: A self hosted media tracker.
repo_topics: django docker docker-compose media-tracker postgresql self-hosted sqlite tailwindcss
code: from django.apps import apps
  from django.template.defaultfilters import pluralize
  
  from app import config, helpers
  from app.models import MediaTypes, Status
  from app.templatetags import app_tags
  
  
  def process_history_entries(history_records, media_type, media_entry_number, user):
      """Process all history records into timeline entries."""
      timeline_entries = []
      last = history_records.first()
  
      for _ in range(history_records.count()):
          entry = process_history_entry((last, last.prev_record), media_type, user)
          if entry["changes"]:
              entry["media_entry_number"] = media_entry_number
              timeline_entries.append(entry)
          last = last.prev_record
  
      return timeline_entries
  
  
  def process_history_entry(entry, media_type, user):
      """Process a single history entry to organize and format changes."""
      new_record, old_record = entry
      processed_entry = {
          "id": new_record.history_id,
          "date": new_record.history_date,
          "changes": [],
      }
  
      if old_record is not None:
          return process_changed_entry(
              new_record,
              old_record,
              media_type,
              processed_entry,
              user,
          )
      return process_creation_entry(new_record, media_type, processed_entry, user)
  
  
  def process_changed_entry(new_record, old_record, media_type, processed_entry, user):
      """Process an entry representing a change to existing media."""
      delta = new_record.diff_against(old_record)
      changes = organize_changes(delta.changes, media_type, user)
      apply_date_status_integration(changes, user)
      build_changes_list(changes, processed_entry)
      return processed_entry
  
  
  def process_creation_entry(new_record, media_type, processed_entry, user):
      """Process an entry representing media creation."""
      history_model = apps.get_model(
          app_label="app",
          model_name=f"historical{media_type}",
      )
  
      changes = collect_creation_changes(new_record, history_model, media_type, user)
      apply_date_status_integration(changes, user)
      build_changes_list(changes, processed_entry)
      return processed_entry
  
  
  def organize_changes(changes, media_type, user):
      """Organize changes into categories."""
      organized = {
          "date_changes": {"start_date": None, "end_date": None},
          "status_change": None,
          "other_changes": [],
      }
  
      end_date_change = None
  
      for change in changes:
          if change.field == "progress" and media_type == MediaTypes.MOVIE.value:
              continue
  
          change_data = {
              "description": format_description(
                  change.field,
                  change.old,
                  change.new,
                  media_type,
                  user,
              ),
              "field": change.field,
              "old": change.old,
              "new": change.new,
          }
  
          if change.field == "status":
              organized["status_change"] = change_data
          elif change.field == "end_date":
              end_date_change = change_data
          elif change.field in organized["date_changes"]:
              organized["date_changes"][change.field] = change_data
          else:
              organized["other_changes"].append(change_data)
  
      if end_date_change:
          organized["date_changes"]["end_date"] = end_date_change
  
      return organized
  
  
  def collect_creation_changes(new_record, history_model, media_type, user):
      """Collect changes for a creation entry."""
      organized = {
          "date_changes": {"start_date": None, "end_date": None},
          "status_change": None,
          "other_changes": [],
      }
  
      for field in history_model._meta.get_fields():
          if (
              field.name.startswith("history_")
              or field.name == "id"
              or not hasattr(new_record, field.attname)
              or (field.name == "progress" and media_type == MediaTypes.MOVIE.value)
          ):
              continue
  
          value = getattr(new_record, field.attname, None)
          if not value and not (
              media_type == MediaTypes.EPISODE.value and field.name == "end_date"
          ):
              continue
  
          change_data = {
              "field": field.name,
              "new": value,
              "description": format_description(
                  field.name,
                  None,
                  value,
                  media_type,
                  user,
              ),
          }
  
          if field.name == "status":
              organized["status_change"] = change_data
          elif field.name in organized["date_changes"]:
              organized["date_changes"][field.name] = change_data
          elif field.name not in ["item", "user", "related_tv"]:
              organized["other_changes"].append(change_data)
  
      return organized
  
  
  def apply_date_status_integration(changes, user):
      """Integrate status changes with date changes where appropriate."""
      date_changes = changes["date_changes"]
      status_change = changes["status_change"]
  
      # Process start date with status
      if (
          date_changes["start_date"]
          and status_change
          and status_change["new"] == Status.IN_PROGRESS.value
      ):
          date_changes["start_date"]["description"] = (
              f"Started on "
              f"{app_tags.datetime_format(date_changes['start_date']['new'], user)}"
          )
          changes["status_change"] = None
  
      # Process end date with status
      if (
          date_changes["end_date"]
          and status_change
          and status_change["new"] == Status.COMPLETED.value
      ):
          date_changes["end_date"]["description"] = (
              f"Finished on "
              f"{app_tags.datetime_format(date_changes['end_date']['new'], user)}"
          )
          changes["status_change"] = None
  
  
  def build_changes_list(changes, processed_entry):
      """Build the final changes list in the desired order."""
      # Add date changes
      if changes["date_changes"]["start_date"]:
          processed_entry["changes"].append(changes["date_changes"]["start_date"])
      if changes["date_changes"]["end_date"]:
          processed_entry["changes"].append(changes["date_changes"]["end_date"])
  
      # Add status if not integrated with dates
      if changes["status_change"]:
          processed_entry["changes"].append(changes["status_change"])
  
      # Add other changes
      processed_entry["changes"].extend(changes["other_changes"])
  
  
  def format_description(field_name, old_value, new_value, media_type=None, user=None):  # noqa: C901, PLR0911, PLR0912
      """Format change description in a human-readable way.
  
      Provides natural language descriptions for various types of changes,
      taking into account the media type and status transitions.
      """
      if field_name in {"start_date", "end_date"}:
          new_value = app_tags.datetime_format(new_value, user)
          old_value = app_tags.datetime_format(old_value, user)
  
      # If old_value is None, treat it as an initial setting
      if old_value is None:
          if field_name == "status":
              verb = config.get_verb(media_type, past_tense=False)
              action = "Marked as"
              if new_value == Status.IN_PROGRESS.value:
                  return f"{action} currently {verb}ing"
              if new_value == Status.COMPLETED.value:
                  return f"{action} finished {verb}ing"
              if new_value == Status.PLANNING.value:
                  return f"Added to {verb}ing list"
              if new_value == Status.DROPPED.value:
                  return f"{action} dropped"
              if new_value == Status.PAUSED.value:
                  return f"{action} paused {verb}ing"
  
          if field_name == "score":
              return f"Rated {new_value}/10"
  
          if field_name == "progress" and media_type:
              verb = config.get_verb(media_type, past_tense=True).title()
              if media_type == MediaTypes.GAME.value:
                  return f"{verb} for {helpers.minutes_to_hhmm(new_value)}"
              unit = config.get_unit(media_type, short=False).lower()
              return f"{verb} up to {unit} {new_value}"
  
          if field_name in ["start_date", "end_date"]:
              field_display = "Started" if field_name == "start_date" else "Finished"
              if new_value:
                  return f"{field_display} on {new_value}"
              return f"{field_display} without date"
  
          if field_name == "notes":
              return "Added notes"
  
          return f"Set {field_name.replace('_', ' ').lower()} to {new_value}"
  
      # Regular change (old_value to new_value)
      if field_name == "status":
          verb = config.get_verb(media_type, past_tense=False)
          # Status transitions
          transitions = {
              (
                  Status.PLANNING.value,
                  Status.IN_PROGRESS.value,
              ): f"Currently {verb}ing",
              (
                  Status.IN_PROGRESS.value,
                  Status.COMPLETED.value,
              ): f"Finished {verb}ing",
              (
                  Status.IN_PROGRESS.value,
                  Status.PAUSED.value,
              ): f"Paused {verb}ing",
              (
                  Status.PAUSED.value,
                  Status.IN_PROGRESS.value,
              ): f"Resumed {verb}ing",
              (
                  Status.IN_PROGRESS.value,
                  Status.DROPPED.value,
              ): f"Stopped {verb}ing",
          }
          return transitions.get(
              (old_value, new_value),
              f"Changed status from {old_value} to {new_value}",
          )
  
      if field_name == "score":
          if old_value == 0:
              return f"Rated {new_value}/10"
          return f"Changed rating from {old_value} to {new_value}"
  
      if field_name == "progress":
          diff = new_value - old_value
          diff_abs = abs(diff)
  
          if media_type == MediaTypes.GAME.value:
              if diff > 0:
                  return f"Added {helpers.minutes_to_hhmm(diff_abs)} of playtime"
              return f"Removed {helpers.minutes_to_hhmm(diff_abs)} of playtime"
  
          unit = (
              f"{config.get_unit(media_type, short=False).lower()}{pluralize(new_value)}"
          )
  
          return f"Progress set to {new_value} {unit}"
  
      if field_name in ["start_date", "end_date"]:
          field_display = "Start" if field_name == "start_date" else "End"
          if not new_value:
              return f"Removed {field_display.lower()} date"
          if not old_value:
              return f"{field_display}ed on {new_value}"
          return f"{field_display} date changed to {new_value}"
  
      if field_name == "notes":
          if not old_value:
              return "Added notes"
          if not new_value:
              return "Removed notes"
          return "Updated notes"
  
      field_label = field_name.replace("_", " ").lower()
      return f"Updated {field_label} from {old_value} to {new_value}"
  