diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..35bf78f5f --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +omit = */test*,celerywyrm*,bookwyrm/migrations/* \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..3bf9f2c5b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.git +.github +.pytest* \ No newline at end of file diff --git a/.env.example b/.env.example index e0e98c103..7a67045cd 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY=7(2w1sedok=aznpq)ta1mc4i%4h=xx@hxwx*o57ctsuml0x%fr +SECRET_KEY="7(2w1sedok=aznpq)ta1mc4i%4h=xx@hxwx*o57ctsuml0x%fr" # SECURITY WARNING: don't run with debug turned on in production! DEBUG=true @@ -25,7 +25,7 @@ POSTGRES_HOST=db CELERY_BROKER=redis://redis:6379/0 CELERY_RESULT_BACKEND=redis://redis:6379/0 -EMAIL_HOST='smtp.mailgun.org' +EMAIL_HOST="smtp.mailgun.org" EMAIL_PORT=587 EMAIL_HOST_USER=mail@your.domain.here EMAIL_HOST_PASSWORD=emailpassword123 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..cfbe05241 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: bookwrym +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..dd84ea782 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..bbcbbe7d6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 000000000..d35f90eb5 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,68 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# ******** NOTE ******** + +name: "CodeQL" + +on: + push: + branches: [ main ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ main ] + schedule: + - cron: '18 6 * * 3' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + language: [ 'javascript', 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] + # Learn more... + # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # ℹī¸ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏ī¸ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/django-tests.yml b/.github/workflows/django-tests.yml new file mode 100644 index 000000000..3ce368ecd --- /dev/null +++ b/.github/workflows/django-tests.yml @@ -0,0 +1,68 @@ +name: Run Python Tests +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: ubuntu-20.04 + strategy: + max-parallel: 4 + matrix: + db: [postgres] + python-version: [3.9] + include: + - db: postgres + db_port: 5432 + + services: + postgres: + image: postgres:10 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: hunter2 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Run Tests + env: + DB: ${{ matrix.db }} + DB_HOST: 127.0.0.1 + DB_PORT: ${{ matrix.db_port }} + DB_PASSWORD: hunter2 + SECRET_KEY: beepbeep + DEBUG: true + DOMAIN: your.domain.here + OL_URL: https://openlibrary.org + BOOKWYRM_DATABASE_BACKEND: postgres + MEDIA_ROOT: images/ + POSTGRES_PASSWORD: hunter2 + POSTGRES_USER: postgres + POSTGRES_DB: github_actions + POSTGRES_HOST: 127.0.0.1 + CELERY_BROKER: "" + CELERY_RESULT_BACKEND: "" + EMAIL_HOST: "smtp.mailgun.org" + EMAIL_PORT: 587 + EMAIL_HOST_USER: "" + EMAIL_HOST_PASSWORD: "" + EMAIL_USE_TLS: true + run: | + python manage.py test diff --git a/.gitignore b/.gitignore index 7d53fd2f5..1384056f2 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ # BookWyrm .env /images/ + +# Testing +.coverage \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index f03d84dd1..7456996e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,15 @@ -FROM python:3 -ENV PYTHONUNBUFFERED 1 +FROM python:3.9 + +ENV PYTHONUNBUFFERED 1 + RUN mkdir /app RUN mkdir /app/static RUN mkdir /app/images + WORKDIR /app + COPY requirements.txt /app/ RUN pip install -r requirements.txt + COPY ./bookwyrm /app COPY ./celerywyrm /app diff --git a/README.md b/README.md index f2a1b1151..51e39541d 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Social reading and reviewing, decentralized with ActivityPub - [The role of federation](#the-role-of-federation) - [Features](#features) - [Setting up the developer environment](#setting-up-the-developer-environment) + - [Installing in Production](#installing-in-production) - [Project structure](#project-structure) - [Book data](#book-data) - [Contributing](#contributing) @@ -34,7 +35,7 @@ Since the project is still in its early stages, not everything here is fully imp - Differentiate local and federated reviews and rating - Track reading activity - Shelve books on default "to-read," "currently reading," and "read" shelves - - Create custom shleves + - Create custom shelves - Store started reading/finished reading dates - Update followers about reading activity (optionally, and with granular privacy controls) - Federation with ActivityPub @@ -59,6 +60,16 @@ cp .env.example .env For most testing, you'll want to use ngrok. Remember to set the DOMAIN in `.env` to your ngrok domain. + +#### With Docker +You'll have to install the Docker and docker-compose. When you're ready, run: + +```bash +docker-compose build +docker-compose run --rm web python manage.py migrate +docker-compose run --rm web python manage.py initdb +``` + ### Without Docker You will need postgres installed and running on your computer. @@ -81,19 +92,56 @@ Initialize the database (or, more specifically, delete the existing database, ru ``` This creates two users, `mouse` with password `password123` and `rat` with password `ratword`. -The application uses Celery and Redis for task management, which must also be configured. (Further instructions pending, sorry! +The application uses Celery and Redis for task management, which must also be installed and configured. And go to the app at `localhost:8000` -#### With Docker -You'll have to install the Docker and docker-compose: -```bash -docker-compose build -docker-compose up -docker-compose exec web python manage.py migrate -docker-compose exec web python manage.py shell -c 'import init_db' -``` + +## Installing in Production + +This project is still young and isn't, at the momoment, very stable, so please procede with caution when running in production. +### Server setup + - Get a domain name and set up DNS for your server + - Set your server up with appropriate firewalls for running a web application (this instruction set is tested again Ubuntu 20.04) + - Set up a mailgun account and the appropriate DNS settings + - Install Docker and docker-compose +### Install and configure BookWyrm + - Get the application code: + `git clone git@github.com:mouse-reeve/bookwyrm.git` + - Switch to the `production` branch + `git checkout production` + - Create your environment variables file + `cp .env.example .env` + - Add your domain, email address, mailgun credentials + - Set a secure redis password and secret key + - Update your nginx configuration in `nginx/default.conf` + - Replace `your-domain.com` with your domain name + - Run the application (this should also set up a Certbot ssl cert for your domain) + `docker-compose up --build` + Make sure all the images build successfully + - When docker has built successfully, stop the process with `CTRL-C` + - Comment out the `command: certonly...` line in `docker-compose.yml` + - Run docker-compose in the background + `docker-compose up -d` + - Initialize the database + `./bw-dev initdb` + - Congrats! You did it, go to your domain and enjoy the fruits of your labors +### Configure your instance + - Register a user account in the applcation UI + - Make your account a superuser (warning: do *not* use django's `createsuperuser` command) + - On your server, open the django shell + `./bw-dev shell` + - Load your user and make it a superuser + ```python + from bookwyrm import models + user = models.User.objects.get(id=1) + user.is_admin = True + user.is_staff = True + user.is_superuser = True + user.save() + ``` + - Go to the admin panel (`/admin/bookwyrm/sitesettings/1/change` on your domain) and set your instance name, description, code of conduct, and toggle whether registration is open on your instance ## Project structure diff --git a/bookwyrm/activitypub/__init__.py b/bookwyrm/activitypub/__init__.py index 03c714a6b..b5b124ec0 100644 --- a/bookwyrm/activitypub/__init__.py +++ b/bookwyrm/activitypub/__init__.py @@ -2,15 +2,19 @@ import inspect import sys -from .base_activity import ActivityEncoder, Image, PublicKey, Signature +from .base_activity import ActivityEncoder, Signature +from .base_activity import Link, Mention +from .base_activity import ActivitySerializerError, resolve_remote_id +from .image import Image from .note import Note, GeneratedNote, Article, Comment, Review, Quotation +from .note import Tombstone from .interaction import Boost, Like from .ordered_collection import OrderedCollection, OrderedCollectionPage -from .person import Person +from .person import Person, PublicKey from .book import Edition, Work, Author -from .verbs import Create, Undo, Update +from .verbs import Create, Delete, Undo, Update from .verbs import Follow, Accept, Reject -from .verbs import Add, Remove +from .verbs import Add, AddBook, Remove # this creates a list of all the Activity types that we can serialize, # so when an Activity comes in from outside, we can check if it's known diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index 042b8a14b..ed19af992 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -2,8 +2,16 @@ from dataclasses import dataclass, fields, MISSING from json import JSONEncoder -from django.db.models.fields.related_descriptors \ - import ForwardManyToOneDescriptor +from django.apps import apps +from django.db import transaction +from django.db.models.fields.files import ImageFileDescriptor +from django.db.models.fields.related_descriptors import ManyToManyDescriptor + +from bookwyrm.connectors import ConnectorException, get_data +from bookwyrm.tasks import app + +class ActivitySerializerError(ValueError): + ''' routine problems serializing activitypub json ''' class ActivityEncoder(JSONEncoder): @@ -13,19 +21,17 @@ class ActivityEncoder(JSONEncoder): @dataclass -class Image: - ''' image block ''' - mediaType: str - url: str - type: str = 'Image' +class Link: + ''' for tagging a book in a status ''' + href: str + name: str + type: str = 'Link' @dataclass -class PublicKey: - ''' public key block ''' - id: str - owner: str - publicKeyPem: str +class Mention(Link): + ''' a subtype of Link for mentioning an actor ''' + type: str = 'Mention' @dataclass @@ -44,55 +50,104 @@ class ActivityObject: type: str def __init__(self, **kwargs): - ''' this lets you pass in an object with fields - that aren't in the dataclass, which it ignores. - Any field in the dataclass is required or has a - default value ''' + ''' this lets you pass in an object with fields that aren't in the + dataclass, which it ignores. Any field in the dataclass is required or + has a default value ''' for field in fields(self): try: value = kwargs[field.name] except KeyError: - if field.default == MISSING: - raise TypeError('Missing required field: %s' % field.name) + if field.default == MISSING and \ + field.default_factory == MISSING: + raise ActivitySerializerError(\ + 'Missing required field: %s' % field.name) value = field.default setattr(self, field.name, value) - def to_model(self, model, instance=None): - ''' convert from an activity to a model ''' + @transaction.atomic + def to_model(self, model, instance=None, save=True): + ''' convert from an activity to a model instance ''' if not isinstance(self, model.activity_serializer): - raise TypeError('Wrong activity type for model') + raise ActivitySerializerError( + 'Wrong activity type "%s" for model "%s" (expects "%s")' % \ + (self.__class__, + model.__name__, + model.activity_serializer) + ) - model_fields = [m.name for m in model._meta.get_fields()] - mapped_fields = {} + # check for an existing instance, if we're not updating a known obj + if not instance: + instance = model.find_existing(self.serialize()) or model() - for mapping in model.activity_mappings: - if mapping.model_key not in model_fields: + many_to_many_fields = {} + image_fields = {} + for field in model._meta.get_fields(): + # check if it's an activitypub field + if not hasattr(field, 'field_to_activity'): + continue + # call the formatter associated with the model field class + value = field.field_from_activity( + getattr(self, field.get_activitypub_field()) + ) + if value is None or value is MISSING: continue - # value is None if there's a default that isn't supplied - # in the activity but is supplied in the formatter - value = None - if mapping.activity_key: - value = getattr(self, mapping.activity_key) - model_field = getattr(model, mapping.model_key) - # remote_id -> foreign key resolver - if isinstance(model_field, ForwardManyToOneDescriptor) and value: - fk_model = model_field.field.related_model - value = resolve_foreign_key(fk_model, value) + model_field = getattr(model, field.name) - mapped_fields[mapping.model_key] = mapping.model_formatter(value) + if isinstance(model_field, ManyToManyDescriptor): + # status mentions book/users for example, stash this for later + many_to_many_fields[field.name] = value + elif isinstance(model_field, ImageFileDescriptor): + # image fields need custom handling + image_fields[field.name] = value + else: + # just a good old fashioned model.field = value + setattr(instance, field.name, value) + # if this isn't here, it messes up saving users. who even knows. + for (model_key, value) in image_fields.items(): + getattr(instance, model_key).save(*value, save=save) - # updating an existing model isntance - if instance: - for k, v in mapped_fields.items(): - setattr(instance, k, v) - instance.save() + if not save: + # we can't set many to many and reverse fields on an unsaved object return instance - # creating a new model instance - return model.objects.create(**mapped_fields) + instance.save() + + # add many to many fields, which have to be set post-save + for (model_key, values) in many_to_many_fields.items(): + # mention books/users, for example + getattr(instance, model_key).set(values) + + if not save or not hasattr(model, 'deserialize_reverse_fields'): + return instance + + # reversed relationships in the models + for (model_field_name, activity_field_name) in \ + model.deserialize_reverse_fields: + # attachments on Status, for example + values = getattr(self, activity_field_name) + if values is None or values is MISSING: + continue + try: + # this is for one to many + related_model = getattr(model, model_field_name).field.model + except AttributeError: + # it's a one to one or foreign key + related_model = getattr(model, model_field_name)\ + .related.related_model + values = [values] + + for item in values: + set_related_field.delay( + related_model.__name__, + instance.__class__.__name__, + instance.__class__.__name__.lower(), + instance.remote_id, + item + ) + return instance def serialize(self): @@ -102,17 +157,57 @@ class ActivityObject: return data -def resolve_foreign_key(model, remote_id): - ''' look up the remote_id on an activity json field ''' - result = model.objects - if hasattr(model.objects, 'select_subclasses'): - result = result.select_subclasses() +@app.task +@transaction.atomic +def set_related_field( + model_name, origin_model_name, + related_field_name, related_remote_id, data): + ''' load reverse related fields (editions, attachments) without blocking ''' + model = apps.get_model('bookwyrm.%s' % model_name, require_ready=True) + origin_model = apps.get_model( + 'bookwyrm.%s' % origin_model_name, + require_ready=True + ) - result = result.filter( - remote_id=remote_id - ).first() + if isinstance(data, str): + item = resolve_remote_id(model, data, save=False) + else: + # look for a match based on all the available data + item = model.find_existing(data) + if not item: + # create a new model instance + item = model.activity_serializer(**data) + item = item.to_model(model, save=False) + # this must exist because it's the object that triggered this function + instance = origin_model.find_existing_by_remote_id(related_remote_id) + if not instance: + raise ValueError('Invalid related remote id: %s' % related_remote_id) - if not result: - raise ValueError('Could not resolve remote_id in %s model: %s' % \ + # edition.parent_work = instance, for example + setattr(item, related_field_name, instance) + item.save() + + +def resolve_remote_id(model, remote_id, refresh=False, save=True): + ''' take a remote_id and return an instance, creating if necessary ''' + result = model.find_existing_by_remote_id(remote_id) + if result and not refresh: + return result + + # load the data and create the object + try: + data = get_data(remote_id) + except (ConnectorException, ConnectionError): + raise ActivitySerializerError( + 'Could not connect to host for remote_id in %s model: %s' % \ (model.__name__, remote_id)) - return result + + # check for existing items with shared unique identifiers + if not result: + result = model.find_existing(data) + if result and not refresh: + return result + + item = model.activity_serializer(**data) + # if we're refreshing, "result" will be set and we'll update it + return item.to_model(model, instance=result, save=save) diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py index 2a50dd6ac..ae9c334da 100644 --- a/bookwyrm/activitypub/book.py +++ b/bookwyrm/activitypub/book.py @@ -2,66 +2,66 @@ from dataclasses import dataclass, field from typing import List -from .base_activity import ActivityObject, Image +from .base_activity import ActivityObject +from .image import Image @dataclass(init=False) class Book(ActivityObject): ''' serializes an edition or work, abstract ''' - authors: List[str] - first_published_date: str - published_date: str - title: str - sort_title: str - subtitle: str - description: str - languages: List[str] - series: str - series_number: str - subjects: List[str] - subject_places: List[str] + sortTitle: str = '' + subtitle: str = '' + description: str = '' + languages: List[str] = field(default_factory=lambda: []) + series: str = '' + seriesNumber: str = '' + subjects: List[str] = field(default_factory=lambda: []) + subjectPlaces: List[str] = field(default_factory=lambda: []) - openlibrary_key: str - librarything_key: str - goodreads_key: str + authors: List[str] = field(default_factory=lambda: []) + firstPublishedDate: str = '' + publishedDate: str = '' - attachment: List[Image] = field(default=lambda: []) + openlibraryKey: str = '' + librarythingKey: str = '' + goodreadsKey: str = '' + + cover: Image = field(default_factory=lambda: {}) type: str = 'Book' @dataclass(init=False) class Edition(Book): ''' Edition instance of a book object ''' - isbn_10: str - isbn_13: str - oclc_number: str - asin: str - pages: str - physical_format: str - publishers: List[str] - work: str + isbn10: str = '' + isbn13: str = '' + oclcNumber: str = '' + asin: str = '' + pages: str = '' + physicalFormat: str = '' + publishers: List[str] = field(default_factory=lambda: []) + type: str = 'Edition' @dataclass(init=False) class Work(Book): ''' work instance of a book object ''' - lccn: str + lccn: str = '' + defaultEdition: str = '' editions: List[str] type: str = 'Work' - @dataclass(init=False) class Author(ActivityObject): ''' author of a book ''' - url: str name: str - born: str - died: str - aliases: str - bio: str - openlibrary_key: str - wikipedia_link: str + born: str = '' + died: str = '' + aliases: str = '' + bio: str = '' + openlibraryKey: str = '' + wikipediaLink: str = '' type: str = 'Person' diff --git a/bookwyrm/activitypub/image.py b/bookwyrm/activitypub/image.py new file mode 100644 index 000000000..569f83c5d --- /dev/null +++ b/bookwyrm/activitypub/image.py @@ -0,0 +1,11 @@ +''' an image, nothing fancy ''' +from dataclasses import dataclass +from .base_activity import ActivityObject + +@dataclass(init=False) +class Image(ActivityObject): + ''' image block ''' + url: str + name: str = '' + type: str = 'Image' + id: str = '' diff --git a/bookwyrm/activitypub/note.py b/bookwyrm/activitypub/note.py index 63ac8a6e0..df28bf8de 100644 --- a/bookwyrm/activitypub/note.py +++ b/bookwyrm/activitypub/note.py @@ -2,21 +2,29 @@ from dataclasses import dataclass, field from typing import Dict, List -from .base_activity import ActivityObject, Image +from .base_activity import ActivityObject, Link +from .image import Image + +@dataclass(init=False) +class Tombstone(ActivityObject): + ''' the placeholder for a deleted status ''' + published: str + deleted: str + type: str = 'Tombstone' + @dataclass(init=False) class Note(ActivityObject): ''' Note activity ''' - url: str - inReplyTo: str published: str attributedTo: str - to: List[str] - cc: List[str] content: str - replies: Dict - # TODO: this is wrong??? - attachment: List[Image] = field(default=lambda: []) + to: List[str] = field(default_factory=lambda: []) + cc: List[str] = field(default_factory=lambda: []) + replies: Dict = field(default_factory=lambda: {}) + inReplyTo: str = '' + tag: List[Link] = field(default_factory=lambda: []) + attachment: List[Image] = field(default_factory=lambda: []) sensitive: bool = False type: str = 'Note' diff --git a/bookwyrm/activitypub/ordered_collection.py b/bookwyrm/activitypub/ordered_collection.py index efd23d5a0..9aeaf6641 100644 --- a/bookwyrm/activitypub/ordered_collection.py +++ b/bookwyrm/activitypub/ordered_collection.py @@ -12,6 +12,7 @@ class OrderedCollection(ActivityObject): first: str last: str = '' name: str = '' + owner: str = '' type: str = 'OrderedCollection' diff --git a/bookwyrm/activitypub/person.py b/bookwyrm/activitypub/person.py index dee6c1f19..88349c02c 100644 --- a/bookwyrm/activitypub/person.py +++ b/bookwyrm/activitypub/person.py @@ -2,7 +2,17 @@ from dataclasses import dataclass, field from typing import Dict -from .base_activity import ActivityObject, Image, PublicKey +from .base_activity import ActivityObject +from .image import Image + + +@dataclass(init=False) +class PublicKey(ActivityObject): + ''' public key block ''' + owner: str + publicKeyPem: str + type: str = 'PublicKey' + @dataclass(init=False) class Person(ActivityObject): @@ -15,8 +25,8 @@ class Person(ActivityObject): summary: str publicKey: PublicKey endpoints: Dict - icon: Image = field(default=lambda: {}) - bookwyrmUser: str = False + icon: Image = field(default_factory=lambda: {}) + bookwyrmUser: bool = False manuallyApprovesFollowers: str = False discoverable: str = True type: str = 'Person' diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py index 1ae106b0f..e890d81fc 100644 --- a/bookwyrm/activitypub/verbs.py +++ b/bookwyrm/activitypub/verbs.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from typing import List from .base_activity import ActivityObject, Signature +from .book import Book @dataclass(init=False) class Verb(ActivityObject): @@ -21,6 +22,14 @@ class Create(Verb): type: str = 'Create' +@dataclass(init=False) +class Delete(Verb): + ''' Create activity ''' + to: List + cc: List + type: str = 'Delete' + + @dataclass(init=False) class Update(Verb): ''' Update activity ''' @@ -61,6 +70,13 @@ class Add(Verb): type: str = 'Add' +@dataclass(init=False) +class AddBook(Verb): + '''Add activity that's aware of the book obj ''' + target: Book + type: str = 'Add' + + @dataclass(init=False) class Remove(Verb): '''Remove activity ''' diff --git a/bookwyrm/admin.py b/bookwyrm/admin.py index 2ea0a1d1e..45af81d99 100644 --- a/bookwyrm/admin.py +++ b/bookwyrm/admin.py @@ -4,3 +4,5 @@ from bookwyrm import models admin.site.register(models.SiteSettings) admin.site.register(models.User) +admin.site.register(models.FederatedServer) +admin.site.register(models.Connector) diff --git a/bookwyrm/books_manager.py b/bookwyrm/books_manager.py index bfc543dec..3b8657686 100644 --- a/bookwyrm/books_manager.py +++ b/bookwyrm/books_manager.py @@ -16,23 +16,6 @@ def get_edition(book_id): return book -def get_or_create_book(remote_id): - ''' pull up a book record by whatever means possible ''' - book = models.Book.objects.select_subclasses().filter( - remote_id=remote_id - ).first() - if book: - return book - - connector = get_or_create_connector(remote_id) - - # raises ConnectorException - book = connector.get_or_create_book(remote_id) - if book: - load_more_data.delay(book.id) - return book - - def get_or_create_connector(remote_id): ''' get the connector related to the author's server ''' url = urlparse(remote_id) @@ -50,7 +33,7 @@ def get_or_create_connector(remote_id): books_url='https://%s/book' % identifier, covers_url='https://%s/images/covers' % identifier, search_url='https://%s/search?q=' % identifier, - priority=3 + priority=2 ) return load_connector(connector_info) @@ -64,14 +47,14 @@ def load_more_data(book_id): connector.expand_book_data(book) -def search(query): +def search(query, min_confidence=0.1): ''' find books based on arbitary keywords ''' results = [] dedup_slug = lambda r: '%s/%s/%s' % (r.title, r.author, r.year) result_index = set() for connector in get_connectors(): try: - result_set = connector.search(query) + result_set = connector.search(query, min_confidence=min_confidence) except HTTPError: continue @@ -87,27 +70,21 @@ def search(query): return results -def local_search(query): +def local_search(query, min_confidence=0.1): ''' only look at local search results ''' connector = load_connector(models.Connector.objects.get(local=True)) - return connector.search(query) + return connector.search(query, min_confidence=min_confidence) -def first_search_result(query): +def first_search_result(query, min_confidence=0.1): ''' search until you find a result that fits ''' for connector in get_connectors(): - result = connector.search(query) + result = connector.search(query, min_confidence=min_confidence) if result: return result[0] return None -def update_book(book, data=None): - ''' re-sync with the original data source ''' - connector = load_connector(book.connector) - connector.update_book(book, data=data) - - def get_connectors(): ''' load all connectors ''' for info in models.Connector.objects.order_by('priority').all(): diff --git a/bookwyrm/broadcast.py b/bookwyrm/broadcast.py index 301fe84f9..a98b6774a 100644 --- a/bookwyrm/broadcast.py +++ b/bookwyrm/broadcast.py @@ -13,7 +13,6 @@ def get_public_recipients(user, software=None): ''' everybody and their public inboxes ''' followers = user.followers.filter(local=False) if software: - # TODO: eventually we may want to handle particular software differently followers = followers.filter(bookwyrm_user=(software == 'bookwyrm')) # we want shared inboxes when available @@ -36,7 +35,6 @@ def broadcast(sender, activity, software=None, \ # start with parsing the direct recipients recipients = [u.inbox for u in direct_recipients or []] # and then add any other recipients - # TODO: other kinds of privacy if privacy == 'public': recipients += get_public_recipients(sender, software=software) broadcast_task.delay( @@ -55,7 +53,6 @@ def broadcast_task(sender_id, activity, recipients): try: sign_and_send(sender, activity, recipient) except requests.exceptions.HTTPError as e: - # TODO: maybe keep track of users who cause errors errors.append({ 'error': str(e), 'recipient': recipient, @@ -64,15 +61,14 @@ def broadcast_task(sender_id, activity, recipients): return errors -def sign_and_send(sender, activity, destination): +def sign_and_send(sender, data, destination): ''' crpyto whatever and http junk ''' now = http_date() - if not sender.private_key: + if not sender.key_pair.private_key: # this shouldn't happen. it would be bad if it happened. raise ValueError('No private key found for sender') - data = json.dumps(activity).encode('utf-8') digest = make_digest(data) response = requests.post( diff --git a/bookwyrm/connectors/__init__.py b/bookwyrm/connectors/__init__.py index b5d93b473..4eb91de41 100644 --- a/bookwyrm/connectors/__init__.py +++ b/bookwyrm/connectors/__init__.py @@ -1,3 +1,4 @@ ''' bring connectors into the namespace ''' from .settings import CONNECTORS from .abstract_connector import ConnectorException +from .abstract_connector import get_data, get_image diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index 602c8d53e..c9f1ad2e6 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -1,32 +1,29 @@ ''' functionality outline for a book data connector ''' from abc import ABC, abstractmethod -from dateutil import parser +from dataclasses import dataclass import pytz -import requests +from urllib3.exceptions import RequestError from django.db import transaction +from dateutil import parser +import requests +from requests import HTTPError +from requests.exceptions import SSLError from bookwyrm import models -class ConnectorException(Exception): +class ConnectorException(HTTPError): ''' when the connector can't do what was asked ''' -class AbstractConnector(ABC): - ''' generic book data connector ''' - +class AbstractMinimalConnector(ABC): + ''' just the bare bones, for other bookwyrm instances ''' def __init__(self, identifier): # load connector settings info = models.Connector.objects.get(identifier=identifier) self.connector = info - self.key_mappings = [] - - # fields we want to look for in book data to copy over - # title we handle separately. - self.book_mappings = [] - # the things in the connector model to copy over self_fields = [ 'base_url', @@ -41,16 +38,7 @@ class AbstractConnector(ABC): for field in self_fields: setattr(self, field, getattr(info, field)) - - def is_available(self): - ''' check if you're allowed to use this connector ''' - if self.max_query_count is not None: - if self.connector.query_count >= self.max_query_count: - return False - return True - - - def search(self, query): + def search(self, query, min_confidence=None): ''' free text search ''' resp = requests.get( '%s%s' % (self.search_url, query), @@ -67,12 +55,43 @@ class AbstractConnector(ABC): results.append(self.format_search_result(doc)) return results - + @abstractmethod def get_or_create_book(self, remote_id): ''' pull up a book record by whatever means possible ''' + + @abstractmethod + def parse_search_data(self, data): + ''' turn the result json from a search into a list ''' + + @abstractmethod + def format_search_result(self, search_result): + ''' create a SearchResult obj from json ''' + + +class AbstractConnector(AbstractMinimalConnector): + ''' generic book data connector ''' + def __init__(self, identifier): + super().__init__(identifier) + + self.key_mappings = [] + + # fields we want to look for in book data to copy over + # title we handle separately. + self.book_mappings = [] + + + def is_available(self): + ''' check if you're allowed to use this connector ''' + if self.max_query_count is not None: + if self.connector.query_count >= self.max_query_count: + return False + return True + + + def get_or_create_book(self, remote_id): # try to load the book book = models.Book.objects.select_subclasses().filter( - remote_id=remote_id + origin_id=remote_id ).first() if book: if isinstance(book, models.Work): @@ -112,22 +131,26 @@ class AbstractConnector(ABC): # remember this hack: re-use the work data as the edition data work_data = data + if not work_data or not edition_data: + raise ConnectorException('Unable to load book data: %s' % remote_id) + # at this point, we need to figure out the work, edition, or both # atomic so that we don't save a work with no edition for vice versa with transaction.atomic(): if not work: - work_key = work_data.get('url') + work_key = self.get_remote_id_from_data(work_data) work = self.create_book(work_key, work_data, models.Work) if not edition: - ed_key = edition_data.get('url') + ed_key = self.get_remote_id_from_data(edition_data) edition = self.create_book(ed_key, edition_data, models.Edition) - edition.default = True edition.parent_work = work edition.save() + work.default_edition = edition + work.save() # now's our change to fill in author gaps - if not edition.authors and work.authors: + if not edition.authors.exists() and work.authors.exists(): edition.authors.set(work.authors.all()) edition.author_text = work.author_text edition.save() @@ -141,7 +164,7 @@ class AbstractConnector(ABC): def create_book(self, remote_id, data, model): ''' create a work or edition from data ''' book = model.objects.create( - remote_id=remote_id, + origin_id=remote_id, title=data['title'], connector=self.connector, ) @@ -152,9 +175,11 @@ class AbstractConnector(ABC): ''' for creating a new book or syncing with data ''' book = update_from_mappings(book, data, self.book_mappings) + author_text = [] for author in self.get_authors_from_data(data): book.authors.add(author) - book.author_text = ', '.join(a.display_name for a in book.authors.all()) + author_text.append(author.name) + book.author_text = ', '.join(author_text) book.save() if not update_cover: @@ -207,6 +232,11 @@ class AbstractConnector(ABC): return None + @abstractmethod + def get_remote_id_from_data(self, data): + ''' otherwise we won't properly set the remote_id in the db ''' + + @abstractmethod def is_work_data(self, data): ''' differentiate works and editions ''' @@ -231,17 +261,6 @@ class AbstractConnector(ABC): def get_cover_from_data(self, data): ''' load cover ''' - - @abstractmethod - def parse_search_data(self, data): - ''' turn the result json from a search into a list ''' - - - @abstractmethod - def format_search_result(self, search_result): - ''' create a SearchResult obj from json ''' - - @abstractmethod def expand_book_data(self, book): ''' get more info on a book ''' @@ -284,25 +303,44 @@ def get_date(date_string): def get_data(url): ''' wrapper for request.get ''' - resp = requests.get( - url, - headers={ - 'Accept': 'application/json; charset=utf-8', - }, - ) + try: + resp = requests.get( + url, + headers={ + 'Accept': 'application/json; charset=utf-8', + }, + ) + except RequestError: + raise ConnectorException() if not resp.ok: resp.raise_for_status() - data = resp.json() + try: + data = resp.json() + except ValueError: + raise ConnectorException() + return data +def get_image(url): + ''' wrapper for requesting an image ''' + try: + resp = requests.get(url) + except (RequestError, SSLError): + return None + if not resp.ok: + return None + return resp + + +@dataclass class SearchResult: ''' standardized search result object ''' - def __init__(self, title, key, author, year): - self.title = title - self.key = key - self.author = author - self.year = year + title: str + key: str + author: str + year: str + confidence: int = 1 def __repr__(self): return "".format( diff --git a/bookwyrm/connectors/bookwyrm_connector.py b/bookwyrm/connectors/bookwyrm_connector.py index 96d94e8ea..e4d32fd33 100644 --- a/bookwyrm/connectors/bookwyrm_connector.py +++ b/bookwyrm/connectors/bookwyrm_connector.py @@ -1,108 +1,16 @@ ''' using another bookwyrm instance as a source of book data ''' -from uuid import uuid4 - -from django.core.exceptions import ObjectDoesNotExist -from django.core.files.base import ContentFile -import requests - -from bookwyrm import models -from .abstract_connector import AbstractConnector, SearchResult, Mapping -from .abstract_connector import update_from_mappings, get_date, get_data +from bookwyrm import activitypub, models +from .abstract_connector import AbstractMinimalConnector, SearchResult -class Connector(AbstractConnector): - ''' interact with other instances ''' - def __init__(self, identifier): - super().__init__(identifier) - self.key_mappings = [ - Mapping('isbn_13', model=models.Edition), - Mapping('isbn_10', model=models.Edition), - Mapping('lccn', model=models.Work), - Mapping('oclc_number', model=models.Edition), - Mapping('openlibrary_key'), - Mapping('goodreads_key'), - Mapping('asin'), - ] - - self.book_mappings = self.key_mappings + [ - Mapping('sort_title'), - Mapping('subtitle'), - Mapping('description'), - Mapping('languages'), - Mapping('series'), - Mapping('series_number'), - Mapping('subjects'), - Mapping('subject_places'), - Mapping('first_published_date'), - Mapping('published_date'), - Mapping('pages'), - Mapping('physical_format'), - Mapping('publishers'), - ] - - self.author_mappings = [ - Mapping('born', remote_field='birth_date', formatter=get_date), - Mapping('died', remote_field='death_date', formatter=get_date), - Mapping('bio'), - ] - - - def is_work_data(self, data): - return data['book_type'] == 'Work' - - - def get_edition_from_work_data(self, data): - return data['editions'][0] - - - def get_work_from_edition_date(self, data): - return data['work'] - - - def get_authors_from_data(self, data): - for author_url in data.get('authors', []): - yield self.get_or_create_author(author_url) - - - def get_cover_from_data(self, data): - cover_data = data.get('attachment') - if not cover_data: - return None - cover_url = cover_data[0].get('url') - response = requests.get(cover_url) - if not response.ok: - response.raise_for_status() - - image_name = str(uuid4()) + cover_url.split('.')[-1] - image_content = ContentFile(response.content) - return [image_name, image_content] - - - def get_or_create_author(self, remote_id): - ''' load that author ''' - try: - return models.Author.objects.get(remote_id=remote_id) - except ObjectDoesNotExist: - pass - - data = get_data(remote_id) - - # ingest a new author - author = models.Author(remote_id=remote_id) - author = update_from_mappings(author, data, self.author_mappings) - author.save() - - return author +class Connector(AbstractMinimalConnector): + ''' this is basically just for search ''' + def get_or_create_book(self, remote_id): + return activitypub.resolve_remote_id(models.Edition, remote_id) def parse_search_data(self, data): return data - def format_search_result(self, search_result): return SearchResult(**search_result) - - - def expand_book_data(self, book): - # TODO - pass diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py index d70ab3e23..28eb1ea0a 100644 --- a/bookwyrm/connectors/openlibrary.py +++ b/bookwyrm/connectors/openlibrary.py @@ -7,8 +7,7 @@ from django.core.files.base import ContentFile from bookwyrm import models from .abstract_connector import AbstractConnector, SearchResult, Mapping from .abstract_connector import ConnectorException -from .abstract_connector import update_from_mappings -from .abstract_connector import get_date, get_data +from .abstract_connector import get_date, get_data, update_from_mappings from .openlibrary_languages import languages @@ -66,12 +65,20 @@ class Connector(AbstractConnector): ] self.author_mappings = [ + Mapping('name'), Mapping('born', remote_field='birth_date', formatter=get_date), Mapping('died', remote_field='death_date', formatter=get_date), Mapping('bio', formatter=get_description), ] + def get_remote_id_from_data(self, data): + try: + key = data['key'] + except KeyError: + raise ConnectorException('Invalid book data') + return '%s/%s' % (self.books_url, key) + def is_work_data(self, data): return bool(re.match(r'^[\/\w]+OL\d+W$', data['key'])) @@ -129,10 +136,10 @@ class Connector(AbstractConnector): key = self.books_url + search_result['key'] author = search_result.get('author_name') or ['Unknown'] return SearchResult( - search_result.get('title'), - key, - ', '.join(author), - search_result.get('first_publish_year'), + title=search_result.get('title'), + key=key, + author=', '.join(author), + year=search_result.get('first_publish_year'), ) @@ -170,21 +177,15 @@ class Connector(AbstractConnector): ''' load that author ''' if not re.match(r'^OL\d+A$', olkey): raise ValueError('Invalid OpenLibrary author ID') - try: - return models.Author.objects.get(openlibrary_key=olkey) - except models.Author.DoesNotExist: - pass + author = models.Author.objects.filter(openlibrary_key=olkey).first() + if author: + return author url = '%s/authors/%s.json' % (self.base_url, olkey) data = get_data(url) author = models.Author(openlibrary_key=olkey) author = update_from_mappings(author, data, self.author_mappings) - name = data.get('name') - # TODO this is making some BOLD assumption - if name: - author.last_name = name.split(' ')[-1] - author.first_name = ' '.join(name.split(' ')[:-1]) author.save() return author diff --git a/bookwyrm/connectors/self_connector.py b/bookwyrm/connectors/self_connector.py index 2711bb1a2..80d3a67d9 100644 --- a/bookwyrm/connectors/self_connector.py +++ b/bookwyrm/connectors/self_connector.py @@ -1,5 +1,6 @@ ''' using a bookwyrm instance as a source of book data ''' from django.contrib.postgres.search import SearchRank, SearchVector +from django.db.models import F from bookwyrm import models from .abstract_connector import AbstractConnector, SearchResult @@ -7,30 +8,33 @@ from .abstract_connector import AbstractConnector, SearchResult class Connector(AbstractConnector): ''' instantiate a connector ''' - def search(self, query): + def search(self, query, min_confidence=0.1): ''' right now you can't search bookwyrm sorry, but when that gets implemented it will totally rule ''' vector = SearchVector('title', weight='A') +\ SearchVector('subtitle', weight='B') +\ - SearchVector('author_text', weight='A') +\ + SearchVector('author_text', weight='C') +\ SearchVector('isbn_13', weight='A') +\ SearchVector('isbn_10', weight='A') +\ - SearchVector('openlibrary_key', weight='B') +\ - SearchVector('goodreads_key', weight='B') +\ - SearchVector('asin', weight='B') +\ - SearchVector('oclc_number', weight='B') +\ - SearchVector('remote_id', weight='B') +\ - SearchVector('description', weight='C') +\ - SearchVector('series', weight='C') + SearchVector('openlibrary_key', weight='C') +\ + SearchVector('goodreads_key', weight='C') +\ + SearchVector('asin', weight='C') +\ + SearchVector('oclc_number', weight='C') +\ + SearchVector('remote_id', weight='C') +\ + SearchVector('description', weight='D') +\ + SearchVector('series', weight='D') results = models.Edition.objects.annotate( search=vector ).annotate( rank=SearchRank(vector, query) ).filter( - rank__gt=0 + rank__gt=min_confidence ).order_by('-rank') - results = results.filter(default=True) or results + + # remove non-default editions, if possible + results = results.filter(parent_work__default_edition__id=F('id')) \ + or results search_results = [] for book in results[:10]: @@ -42,14 +46,18 @@ class Connector(AbstractConnector): def format_search_result(self, search_result): return SearchResult( - search_result.title, - search_result.local_id, - search_result.author_text, - search_result.published_date.year if \ + title=search_result.title, + key=search_result.remote_id, + author=search_result.author_text, + year=search_result.published_date.year if \ search_result.published_date else None, + confidence=search_result.rank, ) + def get_remote_id_from_data(self, data): + pass + def is_work_data(self, data): pass diff --git a/bookwyrm/context_processors.py b/bookwyrm/context_processors.py new file mode 100644 index 000000000..72839dcef --- /dev/null +++ b/bookwyrm/context_processors.py @@ -0,0 +1,8 @@ +''' customize the info available in context for rendering templates ''' +from bookwyrm import models + +def site_settings(request): + ''' include the custom info about the site ''' + return { + 'site': models.SiteSettings.objects.get() + } diff --git a/bookwyrm/emailing.py b/bookwyrm/emailing.py index 12dee65ff..2319d4677 100644 --- a/bookwyrm/emailing.py +++ b/bookwyrm/emailing.py @@ -6,7 +6,6 @@ from bookwyrm.tasks import app def password_reset_email(reset_code): ''' generate a password reset email ''' - # TODO; this should be tempalted site = models.SiteSettings.get() send_email.delay( reset_code.user.email, diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py index 7b18a2ffa..784f10382 100644 --- a/bookwyrm/forms.py +++ b/bookwyrm/forms.py @@ -5,6 +5,7 @@ from collections import defaultdict from django import forms from django.forms import ModelForm, PasswordInput, widgets from django.forms.widgets import Textarea +from django.utils import timezone from bookwyrm import models @@ -29,6 +30,7 @@ class CustomForm(ModelForm): visible.field.widget.attrs['rows'] = None visible.field.widget.attrs['class'] = css_classes[input_type] + class LoginForm(CustomForm): class Meta: model = models.User @@ -52,47 +54,31 @@ class RegisterForm(CustomForm): class RatingForm(CustomForm): class Meta: model = models.Review - fields = ['rating'] + fields = ['user', 'book', 'content', 'rating', 'privacy'] class ReviewForm(CustomForm): class Meta: model = models.Review - fields = ['name', 'content'] - help_texts = {f: None for f in fields} - labels = { - 'name': 'Title', - 'content': 'Review', - } + fields = ['user', 'book', 'name', 'content', 'rating', 'privacy'] class CommentForm(CustomForm): class Meta: model = models.Comment - fields = ['content'] - help_texts = {f: None for f in fields} - labels = { - 'content': 'Comment', - } + fields = ['user', 'book', 'content', 'privacy'] class QuotationForm(CustomForm): class Meta: model = models.Quotation - fields = ['quote', 'content'] - help_texts = {f: None for f in fields} - labels = { - 'quote': 'Quote', - 'content': 'Comment', - } + fields = ['user', 'book', 'quote', 'content', 'privacy'] class ReplyForm(CustomForm): class Meta: model = models.Status - fields = ['content'] - help_texts = {f: None for f in fields} - labels = {'content': 'Comment'} + fields = ['user', 'content', 'reply_parent', 'privacy'] class EditUserForm(CustomForm): @@ -158,7 +144,7 @@ class ExpiryWidget(widgets.Select): else: return selected_string # "This will raise - return datetime.datetime.now() + interval + return timezone.now() + interval class CreateInviteForm(CustomForm): class Meta: @@ -174,3 +160,8 @@ class CreateInviteForm(CustomForm): choices=[(i, "%d uses" % (i,)) for i in [1, 5, 10, 25, 50, 100]] + [(None, 'Unlimited')]) } + +class ShelfForm(CustomForm): + class Meta: + model = models.Shelf + fields = ['user', 'name', 'privacy'] diff --git a/bookwyrm/goodreads_import.py b/bookwyrm/goodreads_import.py index 7b64baa33..3fd330ab4 100644 --- a/bookwyrm/goodreads_import.py +++ b/bookwyrm/goodreads_import.py @@ -1,25 +1,41 @@ ''' handle reading a csv from goodreads ''' import csv -from requests import HTTPError +import logging from bookwyrm import outgoing from bookwyrm.tasks import app from bookwyrm.models import ImportJob, ImportItem from bookwyrm.status import create_notification +logger = logging.getLogger(__name__) # TODO: remove or increase once we're confident it's not causing problems. MAX_ENTRIES = 500 -def create_job(user, csv_file): +def create_job(user, csv_file, include_reviews, privacy): ''' check over a csv and creates a database entry for the job''' - job = ImportJob.objects.create(user=user) + job = ImportJob.objects.create( + user=user, + include_reviews=include_reviews, + privacy=privacy + ) for index, entry in enumerate(list(csv.DictReader(csv_file))[:MAX_ENTRIES]): if not all(x in entry for x in ('ISBN13', 'Title', 'Author')): - raise ValueError("Author, title, and isbn must be in data.") + raise ValueError('Author, title, and isbn must be in data.') ImportItem(job=job, index=index, data=entry).save() return job +def create_retry_job(user, original_job, items): + ''' retry items that didn't import ''' + job = ImportJob.objects.create( + user=user, + include_reviews=original_job.include_reviews, + privacy=original_job.privacy, + retry=True + ) + for item in items: + ImportItem(job=job, index=item.index, data=item.data).save() + return job def start_import(job): ''' initalizes a csv import job ''' @@ -37,18 +53,21 @@ def import_data(job_id): for item in job.items.all(): try: item.resolve() - except HTTPError: - pass + except Exception as e: + logger.exception(e) + item.fail_reason = 'Error loading book' + item.save() + continue + if item.book: item.save() results.append(item) - else: - item.fail_reason = "Could not match book on OpenLibrary" - item.save() - status = outgoing.handle_import_books(job.user, results) - if status: - job.import_status = status - job.save() + # shelves book and handles reviews + outgoing.handle_imported_book( + job.user, item, job.include_reviews, job.privacy) + else: + item.fail_reason = 'Could not find a match for book' + item.save() finally: create_notification(job.user, 'IMPORT', related_import=job) diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py index 144400f9e..bbbebf0fe 100644 --- a/bookwyrm/incoming.py +++ b/bookwyrm/incoming.py @@ -8,9 +8,8 @@ from django.http import HttpResponseBadRequest, HttpResponseNotFound from django.views.decorators.csrf import csrf_exempt import requests -from bookwyrm import activitypub, books_manager, models, outgoing +from bookwyrm import activitypub, models, outgoing from bookwyrm import status as status_builder -from bookwyrm.remote_user import get_or_create_remote_user, refresh_remote_user from bookwyrm.tasks import app from bookwyrm.signatures import Signature @@ -32,12 +31,14 @@ def inbox(request, username): @csrf_exempt def shared_inbox(request): ''' incoming activitypub events ''' - # TODO: should this be functionally different from the non-shared inbox?? if request.method == 'GET': return HttpResponseNotFound() try: - activity = json.loads(request.body) + resp = request.body + activity = json.loads(resp) + if isinstance(activity, str): + activity = json.loads(activity) activity_object = activity['object'] except (json.decoder.JSONDecodeError, KeyError): return HttpResponseBadRequest() @@ -54,18 +55,22 @@ def shared_inbox(request): 'Accept': handle_follow_accept, 'Reject': handle_follow_reject, 'Create': handle_create, + 'Delete': handle_delete_status, 'Like': handle_favorite, 'Announce': handle_boost, 'Add': { - 'Tag': handle_tag, + 'Edition': handle_add, + 'Work': handle_add, }, 'Undo': { 'Follow': handle_unfollow, 'Like': handle_unfavorite, + 'Announce': handle_unboost, }, 'Update': { - 'Person': None,# TODO: handle_update_user - 'Document': handle_update_book, + 'Person': handle_update_user, + 'Edition': handle_update_book, + 'Work': handle_update_book, }, } activity_type = activity['type'] @@ -90,16 +95,20 @@ def has_valid_signature(request, activity): if key_actor != activity.get('actor'): raise ValueError("Wrong actor created signature.") - remote_user = get_or_create_remote_user(key_actor) + remote_user = activitypub.resolve_remote_id(models.User, key_actor) + if not remote_user: + return False try: - signature.verify(remote_user.public_key, request) + signature.verify(remote_user.key_pair.public_key, request) except ValueError: - old_key = remote_user.public_key - refresh_remote_user(remote_user) - if remote_user.public_key == old_key: + old_key = remote_user.key_pair.public_key + remote_user = activitypub.resolve_remote_id( + models.User, remote_user.remote_id, refresh=True + ) + if remote_user.key_pair.public_key == old_key: raise # Key unchanged. - signature.verify(remote_user.public_key, request) + signature.verify(remote_user.key_pair.public_key, request) except (ValueError, requests.exceptions.HTTPError): return False return True @@ -108,47 +117,34 @@ def has_valid_signature(request, activity): @app.task def handle_follow(activity): ''' someone wants to follow a local user ''' - # figure out who they want to follow -- not using get_or_create because - # we only allow you to follow local users - to_follow = models.User.objects.get(remote_id=activity['object']) - # raises models.User.DoesNotExist id the remote id is not found - - # figure out who the actor is - user = get_or_create_remote_user(activity['actor']) try: - relationship = models.UserFollowRequest.objects.create( - user_subject=user, - user_object=to_follow, - relationship_id=activity['id'] - ) + relationship = activitypub.Follow( + **activity + ).to_model(models.UserFollowRequest) except django.db.utils.IntegrityError as err: if err.__cause__.diag.constraint_name != 'userfollowrequest_unique': raise - # Duplicate follow request. Not sure what the correct behaviour is, but - # just dropping it works for now. We should perhaps generate the - # Accept, but then do we need to match the activity id? - return + relationship = models.UserFollowRequest.objects.get( + remote_id=activity['id'] + ) + # send the accept normally for a duplicate request - if not to_follow.manually_approves_followers: - status_builder.create_notification( - to_follow, - 'FOLLOW', - related_user=user - ) - outgoing.handle_accept(user, to_follow, relationship) - else: - status_builder.create_notification( - to_follow, - 'FOLLOW_REQUEST', - related_user=user - ) + manually_approves = relationship.user_object.manually_approves_followers + + status_builder.create_notification( + relationship.user_object, + 'FOLLOW_REQUEST' if manually_approves else 'FOLLOW', + related_user=relationship.user_subject + ) + if not manually_approves: + outgoing.handle_accept(relationship) @app.task def handle_unfollow(activity): ''' unfollow a local user ''' obj = activity['object'] - requester = get_or_create_remote_user(obj['actor']) + requester = activitypub.resolve_remote_id(models.user, obj['actor']) to_unfollow = models.User.objects.get(remote_id=obj['object']) # raises models.User.DoesNotExist @@ -161,7 +157,7 @@ def handle_follow_accept(activity): # figure out who they want to follow requester = models.User.objects.get(remote_id=activity['object']['actor']) # figure out who they are - accepter = get_or_create_remote_user(activity['actor']) + accepter = activitypub.resolve_remote_id(models.User, activity['actor']) try: request = models.UserFollowRequest.objects.get( @@ -178,34 +174,32 @@ def handle_follow_accept(activity): def handle_follow_reject(activity): ''' someone is rejecting a follow request ''' requester = models.User.objects.get(remote_id=activity['object']['actor']) - rejecter = get_or_create_remote_user(activity['actor']) + rejecter = activitypub.resolve_remote_id(models.User, activity['actor']) request = models.UserFollowRequest.objects.get( user_subject=requester, user_object=rejecter ) request.delete() - #raises models.UserFollowRequest.DoesNotExist: + #raises models.UserFollowRequest.DoesNotExist @app.task def handle_create(activity): ''' someone did something, good on them ''' - if activity['object'].get('type') not in \ - ['Note', 'Comment', 'Quotation', 'Review']: - # if it's an article or unknown type, ignore it + # deduplicate incoming activities + status_id = activity['object']['id'] + if models.Status.objects.filter(remote_id=status_id).count(): return - user = get_or_create_remote_user(activity['actor']) - if user.local: - # we really oughtn't even be sending in this case + serializer = activitypub.activity_objects[activity['type']] + status = serializer(**activity) + try: + model = models.activity_models[activity.type] + except KeyError: + # not a type of status we are prepared to deserialize return - # render the json into an activity object - serializer = activitypub.activity_objects[activity['object']['type']] - activity = serializer(**activity['object']) - - # ignore notes that aren't replies to known statuses if activity.type == 'Note': reply = models.Status.objects.filter( remote_id=activity.inReplyTo @@ -213,9 +207,7 @@ def handle_create(activity): if not reply: return - model = models.activity_models[activity.type] - status = activity.to_model(model) - + activity.to_model(model) # create a notification if this is a reply if status.reply_parent and status.reply_parent.user.local: status_builder.create_notification( @@ -226,72 +218,105 @@ def handle_create(activity): ) +@app.task +def handle_delete_status(activity): + ''' remove a status ''' + try: + status_id = activity['object']['id'] + except TypeError: + # this isn't a great fix, because you hit this when mastadon + # is trying to delete a user. + return + try: + status = models.Status.objects.select_subclasses().get( + remote_id=status_id + ) + except models.Status.DoesNotExist: + return + status_builder.delete_status(status) + + @app.task def handle_favorite(activity): ''' approval of your good good post ''' - fav = activitypub.Like(**activity['object']) - # raises ValueError in to_model if a foreign key could not be resolved in + fav = activitypub.Like(**activity) - liker = get_or_create_remote_user(activity['actor']) - if liker.local: + fav = fav.to_model(models.Favorite) + if fav.user.local: return - status = fav.to_model(models.Favorite) - status_builder.create_notification( - status.user, + fav.status.user, 'FAVORITE', - related_user=liker, - related_status=status, + related_user=fav.user, + related_status=fav.status, ) @app.task def handle_unfavorite(activity): ''' approval of your good good post ''' - like = activitypub.Like(**activity['object']) - fav = models.Favorite.objects.filter(remote_id=like.id).first() - - fav.delete() + like = models.Favorite.objects.filter( + remote_id=activity['object']['id'] + ).first() + if not like: + return + like.delete() @app.task def handle_boost(activity): ''' someone gave us a boost! ''' - status_id = activity['object'].split('/')[-1] - status = models.Status.objects.get(id=status_id) - booster = get_or_create_remote_user(activity['actor']) + try: + boost = activitypub.Boost(**activity).to_model(models.Boost) + except activitypub.ActivitySerializerError: + # this probably just means we tried to boost an unknown status + return - if not booster.local: - status_builder.create_boost_from_activity(booster, activity) - - status_builder.create_notification( - status.user, - 'BOOST', - related_user=booster, - related_status=status, - ) + if not boost.user.local: + status_builder.create_notification( + boost.boosted_status.user, + 'BOOST', + related_user=boost.user, + related_status=boost.boosted_status, + ) @app.task -def handle_tag(activity): - ''' someone is tagging a book ''' - user = get_or_create_remote_user(activity['actor']) - if not user.local: - book = activity['target']['id'] - status_builder.create_tag(user, book, activity['object']['name']) +def handle_unboost(activity): + ''' someone gave us a boost! ''' + boost = models.Boost.objects.filter( + remote_id=activity['object']['id'] + ).first() + if boost: + boost.delete() + + +@app.task +def handle_add(activity): + ''' putting a book on a shelf ''' + #this is janky as heck but I haven't thought of a better solution + try: + activitypub.AddBook(**activity).to_model(models.ShelfBook) + except activitypub.ActivitySerializerError: + activitypub.AddBook(**activity).to_model(models.Tag) + + +@app.task +def handle_update_user(activity): + ''' receive an updated user Person activity object ''' + try: + user = models.User.objects.get(remote_id=activity['object']['id']) + except models.User.DoesNotExist: + # who is this person? who cares + return + activitypub.Person( + **activity['object'] + ).to_model(models.User, instance=user) + # model save() happens in the to_model function @app.task def handle_update_book(activity): ''' a remote instance changed a book (Document) ''' - document = activity['object'] - # check if we have their copy and care about their updates - book = models.Book.objects.select_subclasses().filter( - remote_id=document['url'], - sync=True, - ).first() - if not book: - return - - books_manager.update_book(book, data=document) + activitypub.Edition(**activity['object']).to_model(models.Edition) diff --git a/bookwyrm/management/commands/initdb.py b/bookwyrm/management/commands/initdb.py new file mode 100644 index 000000000..9fd117871 --- /dev/null +++ b/bookwyrm/management/commands/initdb.py @@ -0,0 +1,104 @@ +from django.core.management.base import BaseCommand, CommandError +from django.contrib.auth.models import Group, Permission +from django.contrib.contenttypes.models import ContentType + +from bookwyrm.models import Connector, SiteSettings, User +from bookwyrm.settings import DOMAIN + +def init_groups(): + groups = ['admin', 'moderator', 'editor'] + for group in groups: + Group.objects.create(name=group) + +def init_permissions(): + permissions = [{ + 'codename': 'edit_instance_settings', + 'name': 'change the instance info', + 'groups': ['admin',] + }, { + 'codename': 'set_user_group', + 'name': 'change what group a user is in', + 'groups': ['admin', 'moderator'] + }, { + 'codename': 'control_federation', + 'name': 'control who to federate with', + 'groups': ['admin', 'moderator'] + }, { + 'codename': 'create_invites', + 'name': 'issue invitations to join', + 'groups': ['admin', 'moderator'] + }, { + 'codename': 'moderate_user', + 'name': 'deactivate or silence a user', + 'groups': ['admin', 'moderator'] + }, { + 'codename': 'moderate_post', + 'name': 'delete other users\' posts', + 'groups': ['admin', 'moderator'] + }, { + 'codename': 'edit_book', + 'name': 'edit book info', + 'groups': ['admin', 'moderator', 'editor'] + }] + + content_type = ContentType.objects.get_for_model(User) + for permission in permissions: + permission_obj = Permission.objects.create( + codename=permission['codename'], + name=permission['name'], + content_type=content_type, + ) + # add the permission to the appropriate groups + for group_name in permission['groups']: + Group.objects.get(name=group_name).permissions.add(permission_obj) + + # while the groups and permissions shouldn't be changed because the code + # depends on them, what permissions go with what groups should be editable + + +def init_connectors(): + Connector.objects.create( + identifier=DOMAIN, + name='Local', + local=True, + connector_file='self_connector', + base_url='https://%s' % DOMAIN, + books_url='https://%s/book' % DOMAIN, + covers_url='https://%s/images/covers' % DOMAIN, + search_url='https://%s/search?q=' % DOMAIN, + priority=1, + ) + + Connector.objects.create( + identifier='bookwyrm.social', + name='BookWyrm dot Social', + connector_file='bookwyrm_connector', + base_url='https://bookwyrm.social', + books_url='https://bookwyrm.social/book', + covers_url='https://bookwyrm.social/images/covers', + search_url='https://bookwyrm.social/search?q=', + priority=2, + ) + + Connector.objects.create( + identifier='openlibrary.org', + name='OpenLibrary', + connector_file='openlibrary', + base_url='https://openlibrary.org', + books_url='https://openlibrary.org', + covers_url='https://covers.openlibrary.org', + search_url='https://openlibrary.org/search?q=', + priority=3, + ) + +def init_settings(): + SiteSettings.objects.create() + +class Command(BaseCommand): + help = 'Initializes the database with starter data' + + def handle(self, *args, **options): + init_groups() + init_permissions() + init_connectors() + init_settings() diff --git a/bookwyrm/migrations/0006_auto_20200221_1702.py b/bookwyrm/migrations/0006_auto_20200221_1702.py deleted file mode 100644 index 560facc66..000000000 --- a/bookwyrm/migrations/0006_auto_20200221_1702.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-21 17:02 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0005_auto_20200221_1645'), - ] - - operations = [ - migrations.AlterField( - model_name='tag', - name='identifier', - field=models.CharField(max_length=100), - ), - ] diff --git a/bookwyrm/migrations/0006_auto_20200221_1702_squashed_0064_merge_20201101_1913.py b/bookwyrm/migrations/0006_auto_20200221_1702_squashed_0064_merge_20201101_1913.py new file mode 100644 index 000000000..13cb1406a --- /dev/null +++ b/bookwyrm/migrations/0006_auto_20200221_1702_squashed_0064_merge_20201101_1913.py @@ -0,0 +1,1071 @@ +# Generated by Django 3.0.7 on 2020-11-03 00:05 + +import bookwyrm.models.connector +import bookwyrm.models.site +import bookwyrm.utils.fields +from django.conf import settings +import django.contrib.postgres.operations +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import django.db.models.expressions +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0005_auto_20200221_1645'), + ] + + operations = [ + migrations.AlterField( + model_name='tag', + name='identifier', + field=models.CharField(max_length=100), + ), + migrations.AddConstraint( + model_name='userrelationship', + constraint=models.UniqueConstraint(fields=('user_subject', 'user_object'), name='followers_unique'), + ), + migrations.RemoveField( + model_name='user', + name='followers', + ), + migrations.AddField( + model_name='status', + name='published_date', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.CreateModel( + name='Edition', + fields=[ + ('book_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Book')), + ('isbn', models.CharField(max_length=255, null=True, unique=True)), + ('oclc_number', models.CharField(max_length=255, null=True, unique=True)), + ('pages', models.IntegerField(null=True)), + ], + options={ + 'abstract': False, + }, + bases=('bookwyrm.book',), + ), + migrations.CreateModel( + name='Work', + fields=[ + ('book_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Book')), + ('lccn', models.CharField(max_length=255, null=True, unique=True)), + ], + options={ + 'abstract': False, + }, + bases=('bookwyrm.book',), + ), + migrations.RemoveField( + model_name='author', + name='data', + ), + migrations.RemoveField( + model_name='book', + name='added_by', + ), + migrations.RemoveField( + model_name='book', + name='data', + ), + migrations.AddField( + model_name='author', + name='bio', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='author', + name='born', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='author', + name='died', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='author', + name='first_name', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='author', + name='last_name', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='author', + name='name', + field=models.CharField(default='Unknown', max_length=255), + preserve_default=False, + ), + migrations.AddField( + model_name='author', + name='wikipedia_link', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='book', + name='description', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='book', + name='first_published_date', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='book', + name='language', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='book', + name='last_sync_date', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AddField( + model_name='book', + name='librarything_key', + field=models.CharField(max_length=255, null=True, unique=True), + ), + migrations.AddField( + model_name='book', + name='local_edits', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='book', + name='local_key', + field=models.CharField(default=uuid.uuid4, max_length=255, unique=True), + ), + migrations.AddField( + model_name='book', + name='misc_identifiers', + field=bookwyrm.utils.fields.JSONField(null=True), + ), + migrations.AddField( + model_name='book', + name='origin', + field=models.CharField(max_length=255, null=True, unique=True), + ), + migrations.AddField( + model_name='book', + name='published_date', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='book', + name='series', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='book', + name='series_number', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='book', + name='sort_title', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='book', + name='subtitle', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='book', + name='sync', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='book', + name='title', + field=models.CharField(default='Unknown', max_length=255), + preserve_default=False, + ), + migrations.AlterField( + model_name='author', + name='openlibrary_key', + field=models.CharField(max_length=255, null=True, unique=True), + ), + migrations.AlterField( + model_name='book', + name='openlibrary_key', + field=models.CharField(max_length=255, null=True, unique=True), + ), + migrations.AddField( + model_name='book', + name='parent_work', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Work'), + ), + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(auto_now_add=True)), + ('updated_date', models.DateTimeField(auto_now=True)), + ('read', models.BooleanField(default=False)), + ('notification_type', models.CharField(max_length=255)), + ('related_book', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Book')), + ('related_status', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Status')), + ('related_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='related_user', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='author', + name='aliases', + field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None), + ), + migrations.AddField( + model_name='user', + name='manually_approves_followers', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='status', + name='remote_id', + field=models.CharField(max_length=255, null=True, unique=True), + ), + migrations.CreateModel( + name='UserBlocks', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(auto_now_add=True)), + ('updated_date', models.DateTimeField(auto_now=True)), + ('relationship_id', models.CharField(max_length=100)), + ('user_object', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userblocks_user_object', to=settings.AUTH_USER_MODEL)), + ('user_subject', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userblocks_user_subject', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='UserFollowRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(auto_now_add=True)), + ('updated_date', models.DateTimeField(auto_now=True)), + ('relationship_id', models.CharField(max_length=100)), + ('user_object', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollowrequest_user_object', to=settings.AUTH_USER_MODEL)), + ('user_subject', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollowrequest_user_subject', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='UserFollows', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(auto_now_add=True)), + ('updated_date', models.DateTimeField(auto_now=True)), + ('relationship_id', models.CharField(max_length=100)), + ('user_object', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollows_user_object', to=settings.AUTH_USER_MODEL)), + ('user_subject', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollows_user_subject', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.DeleteModel( + name='UserRelationship', + ), + migrations.AddField( + model_name='user', + name='blocks', + field=models.ManyToManyField(related_name='blocked_by', through='bookwyrm.UserBlocks', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='user', + name='follow_requests', + field=models.ManyToManyField(related_name='follower_requests', through='bookwyrm.UserFollowRequest', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='user', + name='following', + field=models.ManyToManyField(related_name='followers', through='bookwyrm.UserFollows', to=settings.AUTH_USER_MODEL), + ), + migrations.AddConstraint( + model_name='userfollows', + constraint=models.UniqueConstraint(fields=('user_subject', 'user_object'), name='userfollows_unique'), + ), + migrations.AddConstraint( + model_name='userfollowrequest', + constraint=models.UniqueConstraint(fields=('user_subject', 'user_object'), name='userfollowrequest_unique'), + ), + migrations.AddConstraint( + model_name='userblocks', + constraint=models.UniqueConstraint(fields=('user_subject', 'user_object'), name='userblocks_unique'), + ), + migrations.AlterField( + model_name='notification', + name='notification_type', + field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request')], max_length=255), + ), + migrations.AddConstraint( + model_name='notification', + constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST']), name='notification_type_valid'), + ), + migrations.AddConstraint( + model_name='userblocks', + constraint=models.CheckConstraint(check=models.Q(_negated=True, user_subject=django.db.models.expressions.F('user_object')), name='userblocks_no_self'), + ), + migrations.AddConstraint( + model_name='userfollowrequest', + constraint=models.CheckConstraint(check=models.Q(_negated=True, user_subject=django.db.models.expressions.F('user_object')), name='userfollowrequest_no_self'), + ), + migrations.AddConstraint( + model_name='userfollows', + constraint=models.CheckConstraint(check=models.Q(_negated=True, user_subject=django.db.models.expressions.F('user_object')), name='userfollows_no_self'), + ), + migrations.AddField( + model_name='favorite', + name='remote_id', + field=models.CharField(max_length=255, null=True, unique=True), + ), + migrations.CreateModel( + name='Comment', + fields=[ + ('status_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Status')), + ('name', models.CharField(max_length=255)), + ('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Book')), + ], + options={ + 'abstract': False, + }, + bases=('bookwyrm.status',), + ), + migrations.CreateModel( + name='Connector', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(auto_now_add=True)), + ('updated_date', models.DateTimeField(auto_now=True)), + ('identifier', models.CharField(max_length=255, unique=True)), + ('connector_file', models.CharField(choices=[('openlibrary', 'Openlibrary'), ('bookwyrm', 'BookWyrm')], default='openlibrary', max_length=255)), + ('is_self', models.BooleanField(default=False)), + ('api_key', models.CharField(max_length=255, null=True)), + ('base_url', models.CharField(max_length=255)), + ('covers_url', models.CharField(max_length=255)), + ('search_url', models.CharField(max_length=255, null=True)), + ('key_name', models.CharField(max_length=255)), + ('politeness_delay', models.IntegerField(null=True)), + ('max_query_count', models.IntegerField(null=True)), + ('query_count', models.IntegerField(default=0)), + ('query_count_expiry', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.RenameField( + model_name='book', + old_name='local_key', + new_name='fedireads_key', + ), + migrations.RenameField( + model_name='book', + old_name='origin', + new_name='source_url', + ), + migrations.RemoveField( + model_name='book', + name='local_edits', + ), + migrations.AddConstraint( + model_name='connector', + constraint=models.CheckConstraint(check=models.Q(connector_file__in=bookwyrm.models.connector.ConnectorFiles), name='connector_file_valid'), + ), + migrations.AddField( + model_name='book', + name='connector', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Connector'), + ), + migrations.AddField( + model_name='book', + name='subject_places', + field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None), + ), + migrations.AddField( + model_name='book', + name='subjects', + field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None), + ), + migrations.AddField( + model_name='edition', + name='publishers', + field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None), + ), + migrations.AlterField( + model_name='connector', + name='connector_file', + field=models.CharField(choices=[('openlibrary', 'Openlibrary'), ('fedireads_connector', 'Fedireads Connector')], default='openlibrary', max_length=255), + ), + migrations.RemoveField( + model_name='connector', + name='is_self', + ), + migrations.AlterField( + model_name='connector', + name='connector_file', + field=models.CharField(choices=[('openlibrary', 'Openlibrary'), ('self_connector', 'Self Connector'), ('fedireads_connector', 'Fedireads Connector')], default='openlibrary', max_length=255), + ), + migrations.AddField( + model_name='book', + name='sync_cover', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='author', + name='born', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='author', + name='died', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='author', + name='fedireads_key', + field=models.CharField(default=uuid.uuid4, max_length=255, unique=True), + ), + migrations.AlterField( + model_name='author', + name='first_name', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='author', + name='last_name', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='author', + name='openlibrary_key', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='book', + name='first_published_date', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='book', + name='goodreads_key', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='book', + name='language', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='book', + name='librarything_key', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='book', + name='openlibrary_key', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='book', + name='published_date', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='book', + name='sort_title', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='book', + name='subtitle', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='edition', + name='isbn', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='edition', + name='oclc_number', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='edition', + name='pages', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='edition', + name='physical_format', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='work', + name='lccn', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='federatedserver', + name='application_version', + field=models.CharField(max_length=255, null=True), + ), + migrations.AlterField( + model_name='book', + name='last_sync_date', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AlterField( + model_name='status', + name='published_date', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.CreateModel( + name='Boost', + fields=[ + ('status_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Status')), + ], + options={ + 'abstract': False, + }, + bases=('bookwyrm.status',), + ), + migrations.RemoveConstraint( + model_name='notification', + name='notification_type_valid', + ), + migrations.AlterField( + model_name='notification', + name='notification_type', + field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request'), ('BOOST', 'Boost')], max_length=255), + ), + migrations.AddConstraint( + model_name='notification', + constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST', 'BOOST']), name='notification_type_valid'), + ), + migrations.AddField( + model_name='boost', + name='boosted_status', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='boosters', to='bookwyrm.Status'), + ), + migrations.RemoveField( + model_name='book', + name='language', + ), + migrations.RemoveField( + model_name='book', + name='parent_work', + ), + migrations.RemoveField( + model_name='book', + name='shelves', + ), + migrations.AddField( + model_name='book', + name='languages', + field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None), + ), + migrations.AddField( + model_name='edition', + name='default', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='edition', + name='parent_work', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Work'), + ), + migrations.AddField( + model_name='edition', + name='shelves', + field=models.ManyToManyField(through='bookwyrm.ShelfBook', to='bookwyrm.Shelf'), + ), + migrations.AlterField( + model_name='comment', + name='book', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), + ), + migrations.AlterField( + model_name='notification', + name='related_book', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), + ), + migrations.AlterField( + model_name='review', + name='book', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), + ), + migrations.AlterField( + model_name='shelf', + name='books', + field=models.ManyToManyField(through='bookwyrm.ShelfBook', to='bookwyrm.Edition'), + ), + migrations.AlterField( + model_name='shelfbook', + name='book', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), + ), + migrations.AlterField( + model_name='status', + name='mention_books', + field=models.ManyToManyField(related_name='mention_book', to='bookwyrm.Edition'), + ), + migrations.AlterField( + model_name='tag', + name='book', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), + ), + migrations.RemoveField( + model_name='comment', + name='name', + ), + migrations.AlterField( + model_name='review', + name='rating', + field=models.IntegerField(blank=True, default=None, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)]), + ), + migrations.AlterField( + model_name='review', + name='name', + field=models.CharField(max_length=255, null=True), + ), + migrations.CreateModel( + name='Quotation', + fields=[ + ('status_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Status')), + ('quote', models.TextField()), + ('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition')), + ], + options={ + 'abstract': False, + }, + bases=('bookwyrm.status',), + ), + migrations.CreateModel( + name='ReadThrough', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(auto_now_add=True)), + ('updated_date', models.DateTimeField(auto_now=True)), + ('pages_read', models.IntegerField(blank=True, null=True)), + ('start_date', models.DateTimeField(blank=True, null=True)), + ('finish_date', models.DateTimeField(blank=True, null=True)), + ('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Book')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ImportItem', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('data', bookwyrm.utils.fields.JSONField()), + ], + ), + migrations.CreateModel( + name='ImportJob', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(default=django.utils.timezone.now)), + ('task_id', models.CharField(max_length=100, null=True)), + ], + ), + migrations.RemoveConstraint( + model_name='notification', + name='notification_type_valid', + ), + migrations.AlterField( + model_name='notification', + name='notification_type', + field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request'), ('BOOST', 'Boost'), ('IMPORT_RESULT', 'Import Result')], max_length=255), + ), + migrations.AddConstraint( + model_name='notification', + constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST', 'BOOST', 'IMPORT_RESULT']), name='notification_type_valid'), + ), + migrations.AddField( + model_name='importjob', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='importitem', + name='book', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='bookwyrm.Book'), + ), + migrations.AddField( + model_name='importitem', + name='job', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='bookwyrm.ImportJob'), + ), + migrations.RemoveConstraint( + model_name='notification', + name='notification_type_valid', + ), + migrations.AddField( + model_name='importitem', + name='fail_reason', + field=models.TextField(null=True), + ), + migrations.AddField( + model_name='importitem', + name='index', + field=models.IntegerField(default=1), + preserve_default=False, + ), + migrations.AddField( + model_name='notification', + name='related_import', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.ImportJob'), + ), + migrations.AlterField( + model_name='notification', + name='notification_type', + field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request'), ('BOOST', 'Boost'), ('IMPORT', 'Import')], max_length=255), + ), + migrations.AddConstraint( + model_name='notification', + constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST', 'BOOST', 'IMPORT']), name='notification_type_valid'), + ), + migrations.RenameField( + model_name='edition', + old_name='isbn', + new_name='isbn_13', + ), + migrations.AddField( + model_name='book', + name='author_text', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='edition', + name='asin', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='edition', + name='isbn_10', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='connector', + name='books_url', + field=models.CharField(default='https://openlibrary.org', max_length=255), + preserve_default=False, + ), + migrations.AddField( + model_name='connector', + name='local', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='connector', + name='name', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='connector', + name='priority', + field=models.IntegerField(default=2), + ), + migrations.AlterField( + model_name='connector', + name='connector_file', + field=models.CharField(choices=[('openlibrary', 'Openlibrary'), ('self_connector', 'Self Connector'), ('fedireads_connector', 'Fedireads Connector')], max_length=255), + ), + migrations.RemoveField( + model_name='author', + name='fedireads_key', + ), + migrations.RemoveField( + model_name='book', + name='fedireads_key', + ), + migrations.RemoveField( + model_name='book', + name='source_url', + ), + migrations.AddField( + model_name='author', + name='last_sync_date', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AddField( + model_name='author', + name='sync', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='book', + name='remote_id', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='author', + name='remote_id', + field=models.CharField(max_length=255, null=True), + ), + migrations.RemoveField( + model_name='book', + name='misc_identifiers', + ), + migrations.RemoveField( + model_name='connector', + name='key_name', + ), + migrations.RemoveField( + model_name='user', + name='actor', + ), + migrations.AddField( + model_name='connector', + name='remote_id', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='federatedserver', + name='remote_id', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='notification', + name='remote_id', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='readthrough', + name='remote_id', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='shelf', + name='remote_id', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='shelfbook', + name='remote_id', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='tag', + name='remote_id', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='userblocks', + name='remote_id', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='userfollowrequest', + name='remote_id', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='userfollows', + name='remote_id', + field=models.CharField(max_length=255, null=True), + ), + migrations.AlterField( + model_name='favorite', + name='remote_id', + field=models.CharField(max_length=255, null=True), + ), + migrations.AlterField( + model_name='status', + name='remote_id', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='user', + name='remote_id', + field=models.CharField(max_length=255, null=True, unique=True), + ), + migrations.CreateModel( + name='SiteInvite', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(default=bookwyrm.models.site.new_access_code, max_length=32)), + ('expiry', models.DateTimeField(blank=True, null=True)), + ('use_limit', models.IntegerField(blank=True, null=True)), + ('times_used', models.IntegerField(default=0)), + ('user', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.RemoveField( + model_name='status', + name='activity_type', + ), + migrations.RemoveField( + model_name='status', + name='status_type', + ), + migrations.RenameField( + model_name='user', + old_name='fedireads_user', + new_name='bookwyrm_user', + ), + migrations.AlterField( + model_name='connector', + name='connector_file', + field=models.CharField(choices=[('openlibrary', 'Openlibrary'), ('self_connector', 'Self Connector'), ('bookwyrm_connector', 'BookWyrm Connector')], max_length=255), + ), + migrations.AlterField( + model_name='connector', + name='connector_file', + field=models.CharField(choices=[('openlibrary', 'Openlibrary'), ('self_connector', 'Self Connector'), ('bookwyrm_connector', 'Bookwyrm Connector')], max_length=255), + ), + migrations.CreateModel( + name='GeneratedStatus', + fields=[ + ('status_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Status')), + ], + options={ + 'abstract': False, + }, + bases=('bookwyrm.status',), + ), + migrations.CreateModel( + name='PasswordReset', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(default=bookwyrm.models.site.new_access_code, max_length=32)), + ('expiry', models.DateTimeField(default=bookwyrm.models.site.get_passowrd_reset_expiry)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AlterField( + model_name='user', + name='email', + field=models.EmailField(max_length=254, unique=True), + ), + migrations.CreateModel( + name='SiteSettings', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='BookWyrm', max_length=100)), + ('instance_description', models.TextField(default='This instance has no description.')), + ('code_of_conduct', models.TextField(default='Add a code of conduct here.')), + ('allow_registration', models.BooleanField(default=True)), + ], + ), + migrations.AlterField( + model_name='user', + name='email', + field=models.EmailField(blank=True, max_length=254, verbose_name='email address'), + ), + migrations.AddField( + model_name='status', + name='deleted', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='status', + name='deleted_date', + field=models.DateTimeField(), + ), + django.contrib.postgres.operations.TrigramExtension( + ), + migrations.RemoveField( + model_name='userblocks', + name='relationship_id', + ), + migrations.RemoveField( + model_name='userfollowrequest', + name='relationship_id', + ), + migrations.RemoveField( + model_name='userfollows', + name='relationship_id', + ), + migrations.AlterField( + model_name='status', + name='deleted_date', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='status', + name='privacy', + field=models.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255), + ), + migrations.AddField( + model_name='importjob', + name='include_reviews', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='importjob', + name='privacy', + field=models.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255), + ), + migrations.AlterField( + model_name='user', + name='federated_server', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.FederatedServer'), + ), + migrations.RenameModel( + old_name='GeneratedStatus', + new_name='GeneratedNote', + ), + migrations.AlterField( + model_name='connector', + name='api_key', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='connector', + name='max_query_count', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='connector', + name='name', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='connector', + name='politeness_delay', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='connector', + name='search_url', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='user', + name='last_active_date', + field=models.DateTimeField(auto_now=True), + ), + migrations.RemoveConstraint( + model_name='notification', + name='notification_type_valid', + ), + migrations.AlterField( + model_name='notification', + name='notification_type', + field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('MENTION', 'Mention'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request'), ('BOOST', 'Boost'), ('IMPORT', 'Import')], max_length=255), + ), + migrations.AddConstraint( + model_name='notification', + constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'MENTION', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST', 'BOOST', 'IMPORT']), name='notification_type_valid'), + ), + ] diff --git a/bookwyrm/migrations/0007_auto_20200223_0902.py b/bookwyrm/migrations/0007_auto_20200223_0902.py deleted file mode 100644 index 3b9011e75..000000000 --- a/bookwyrm/migrations/0007_auto_20200223_0902.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-23 09:02 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0006_auto_20200221_1702'), - ] - - operations = [ - migrations.AddConstraint( - model_name='userrelationship', - constraint=models.UniqueConstraint(fields=('user_subject', 'user_object'), name='followers_unique'), - ), - ] diff --git a/bookwyrm/migrations/0044_siteinvite_user.py b/bookwyrm/migrations/0007_auto_20201103_0014.py similarity index 50% rename from bookwyrm/migrations/0044_siteinvite_user.py rename to bookwyrm/migrations/0007_auto_20201103_0014.py index fdf99866b..bf0a12eb0 100644 --- a/bookwyrm/migrations/0044_siteinvite_user.py +++ b/bookwyrm/migrations/0007_auto_20201103_0014.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.3 on 2020-06-02 15:46 +# Generated by Django 3.0.7 on 2020-11-03 00:14 from django.conf import settings from django.db import migrations, models @@ -8,14 +8,13 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('bookwyrm', '0043_siteinvite'), + ('bookwyrm', '0006_auto_20200221_1702_squashed_0064_merge_20201101_1913'), ] operations = [ - migrations.AddField( + migrations.AlterField( model_name='siteinvite', name='user', - field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - preserve_default=False, + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), ), ] diff --git a/bookwyrm/migrations/0008_auto_20200224_1504.py b/bookwyrm/migrations/0008_auto_20200224_1504.py deleted file mode 100644 index 0dbaefec9..000000000 --- a/bookwyrm/migrations/0008_auto_20200224_1504.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-24 15:04 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0007_auto_20200223_0902'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='followers', - field=models.ManyToManyField(related_name='following', through='bookwyrm.UserRelationship', to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/bookwyrm/migrations/0008_work_default_edition.py b/bookwyrm/migrations/0008_work_default_edition.py new file mode 100644 index 000000000..da1f959e8 --- /dev/null +++ b/bookwyrm/migrations/0008_work_default_edition.py @@ -0,0 +1,35 @@ +# Generated by Django 3.0.7 on 2020-11-04 18:15 + +from django.db import migrations, models +import django.db.models.deletion + + +def set_default_edition(app_registry, schema_editor): + db_alias = schema_editor.connection.alias + works = app_registry.get_model('bookwyrm', 'Work').objects.using(db_alias) + editions = app_registry.get_model('bookwyrm', 'Edition').objects.using(db_alias) + for work in works: + ed = editions.filter(parent_work=work, default=True).first() + if not ed: + ed = editions.filter(parent_work=work).first() + work.default_edition = ed + work.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0007_auto_20201103_0014'), + ] + + operations = [ + migrations.AddField( + model_name='work', + name='default_edition', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), + ), + migrations.RunPython(set_default_edition), + migrations.RemoveField( + model_name='edition', + name='default', + ), + ] diff --git a/bookwyrm/migrations/0009_shelf_privacy.py b/bookwyrm/migrations/0009_shelf_privacy.py new file mode 100644 index 000000000..8232c2edc --- /dev/null +++ b/bookwyrm/migrations/0009_shelf_privacy.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.7 on 2020-11-10 20:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0008_work_default_edition'), + ] + + operations = [ + migrations.AddField( + model_name='shelf', + name='privacy', + field=models.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255), + ), + ] diff --git a/bookwyrm/migrations/0009_status_published_date.py b/bookwyrm/migrations/0009_status_published_date.py deleted file mode 100644 index 9e7e726df..000000000 --- a/bookwyrm/migrations/0009_status_published_date.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-07 00:28 - -import datetime -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0008_auto_20200224_1504'), - ] - - operations = [ - migrations.AddField( - model_name='status', - name='published_date', - field=models.DateTimeField(default=datetime.datetime.now), - ), - ] diff --git a/bookwyrm/migrations/0010_auto_20200307_0655.py b/bookwyrm/migrations/0010_auto_20200307_0655.py deleted file mode 100644 index f92792585..000000000 --- a/bookwyrm/migrations/0010_auto_20200307_0655.py +++ /dev/null @@ -1,190 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-07 06:55 - -import datetime -from django.db import migrations, models -import django.db.models.deletion -import bookwyrm.utils.fields -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0009_status_published_date'), - ] - - operations = [ - migrations.CreateModel( - name='Edition', - fields=[ - ('book_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Book')), - ('isbn', models.CharField(max_length=255, null=True, unique=True)), - ('oclc_number', models.CharField(max_length=255, null=True, unique=True)), - ('pages', models.IntegerField(null=True)), - ], - options={ - 'abstract': False, - }, - bases=('bookwyrm.book',), - ), - migrations.CreateModel( - name='Work', - fields=[ - ('book_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Book')), - ('lccn', models.CharField(max_length=255, null=True, unique=True)), - ], - options={ - 'abstract': False, - }, - bases=('bookwyrm.book',), - ), - migrations.RemoveField( - model_name='author', - name='data', - ), - migrations.RemoveField( - model_name='book', - name='added_by', - ), - migrations.RemoveField( - model_name='book', - name='data', - ), - migrations.AddField( - model_name='author', - name='aliases', - field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, size=None), - ), - migrations.AddField( - model_name='author', - name='bio', - field=models.TextField(blank=True, null=True), - ), - migrations.AddField( - model_name='author', - name='born', - field=models.DateTimeField(null=True), - ), - migrations.AddField( - model_name='author', - name='died', - field=models.DateTimeField(null=True), - ), - migrations.AddField( - model_name='author', - name='first_name', - field=models.CharField(max_length=255, null=True), - ), - migrations.AddField( - model_name='author', - name='last_name', - field=models.CharField(max_length=255, null=True), - ), - migrations.AddField( - model_name='author', - name='name', - field=models.CharField(default='Unknown', max_length=255), - preserve_default=False, - ), - migrations.AddField( - model_name='author', - name='wikipedia_link', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AddField( - model_name='book', - name='description', - field=models.TextField(blank=True, null=True), - ), - migrations.AddField( - model_name='book', - name='first_published_date', - field=models.DateTimeField(null=True), - ), - migrations.AddField( - model_name='book', - name='language', - field=models.CharField(max_length=255, null=True), - ), - migrations.AddField( - model_name='book', - name='last_sync_date', - field=models.DateTimeField(default=datetime.datetime.now), - ), - migrations.AddField( - model_name='book', - name='librarything_key', - field=models.CharField(max_length=255, null=True, unique=True), - ), - migrations.AddField( - model_name='book', - name='local_edits', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='book', - name='local_key', - field=models.CharField(default=uuid.uuid4, max_length=255, unique=True), - ), - migrations.AddField( - model_name='book', - name='misc_identifiers', - field=bookwyrm.utils.fields.JSONField(null=True), - ), - migrations.AddField( - model_name='book', - name='origin', - field=models.CharField(max_length=255, null=True, unique=True), - ), - migrations.AddField( - model_name='book', - name='published_date', - field=models.DateTimeField(null=True), - ), - migrations.AddField( - model_name='book', - name='series', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AddField( - model_name='book', - name='series_number', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AddField( - model_name='book', - name='sort_title', - field=models.CharField(max_length=255, null=True), - ), - migrations.AddField( - model_name='book', - name='subtitle', - field=models.TextField(blank=True, null=True), - ), - migrations.AddField( - model_name='book', - name='sync', - field=models.BooleanField(default=True), - ), - migrations.AddField( - model_name='book', - name='title', - field=models.CharField(default='Unknown', max_length=255), - preserve_default=False, - ), - migrations.AlterField( - model_name='author', - name='openlibrary_key', - field=models.CharField(max_length=255, null=True, unique=True), - ), - migrations.AlterField( - model_name='book', - name='openlibrary_key', - field=models.CharField(max_length=255, null=True, unique=True), - ), - migrations.AddField( - model_name='book', - name='parent_work', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Work'), - ), - ] diff --git a/bookwyrm/migrations/0013_user_manually_approves_followers.py b/bookwyrm/migrations/0010_importjob_retry.py similarity index 56% rename from bookwyrm/migrations/0013_user_manually_approves_followers.py rename to bookwyrm/migrations/0010_importjob_retry.py index 3fd83d917..21296cc45 100644 --- a/bookwyrm/migrations/0013_user_manually_approves_followers.py +++ b/bookwyrm/migrations/0010_importjob_retry.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.3 on 2020-03-09 20:09 +# Generated by Django 3.0.7 on 2020-11-13 15:54 from django.db import migrations, models @@ -6,13 +6,13 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('bookwyrm', '0012_auto_20200308_1625'), + ('bookwyrm', '0009_shelf_privacy'), ] operations = [ migrations.AddField( - model_name='user', - name='manually_approves_followers', + model_name='importjob', + name='retry', field=models.BooleanField(default=False), ), ] diff --git a/bookwyrm/migrations/0011_auto_20201113_1727.py b/bookwyrm/migrations/0011_auto_20201113_1727.py new file mode 100644 index 000000000..15e853a35 --- /dev/null +++ b/bookwyrm/migrations/0011_auto_20201113_1727.py @@ -0,0 +1,33 @@ +# Generated by Django 3.0.7 on 2020-11-13 17:27 + +from django.db import migrations, models + +def set_origin_id(app_registry, schema_editor): + db_alias = schema_editor.connection.alias + books = app_registry.get_model('bookwyrm', 'Book').objects.using(db_alias) + for book in books: + book.origin_id = book.remote_id + # the remote_id will be set automatically + book.remote_id = None + book.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0010_importjob_retry'), + ] + + operations = [ + migrations.AddField( + model_name='author', + name='origin_id', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='book', + name='origin_id', + field=models.CharField(max_length=255, null=True), + ), + migrations.RunPython(set_origin_id), + ] diff --git a/bookwyrm/migrations/0011_notification.py b/bookwyrm/migrations/0011_notification.py deleted file mode 100644 index 0d8f333ea..000000000 --- a/bookwyrm/migrations/0011_notification.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-07 22:23 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0010_auto_20200307_0655'), - ] - - operations = [ - migrations.CreateModel( - name='Notification', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_date', models.DateTimeField(auto_now_add=True)), - ('updated_date', models.DateTimeField(auto_now=True)), - ('read', models.BooleanField(default=False)), - ('notification_type', models.CharField(max_length=255)), - ('related_book', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Book')), - ('related_status', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Status')), - ('related_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='related_user', to=settings.AUTH_USER_MODEL)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/bookwyrm/migrations/0012_attachment.py b/bookwyrm/migrations/0012_attachment.py new file mode 100644 index 000000000..495538517 --- /dev/null +++ b/bookwyrm/migrations/0012_attachment.py @@ -0,0 +1,29 @@ +# Generated by Django 3.0.7 on 2020-11-24 19:39 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0011_auto_20201113_1727'), + ] + + operations = [ + migrations.CreateModel( + name='Attachment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(auto_now_add=True)), + ('updated_date', models.DateTimeField(auto_now=True)), + ('remote_id', models.CharField(max_length=255, null=True)), + ('image', models.ImageField(blank=True, null=True, upload_to='status/')), + ('caption', models.TextField(blank=True, null=True)), + ('status', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='bookwyrm.Status')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/bookwyrm/migrations/0012_auto_20200308_1625.py b/bookwyrm/migrations/0012_auto_20200308_1625.py deleted file mode 100644 index 65c12da79..000000000 --- a/bookwyrm/migrations/0012_auto_20200308_1625.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-08 16:25 - -from django.db import migrations, models -import bookwyrm.utils.fields - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0011_notification'), - ] - - operations = [ - migrations.AlterField( - model_name='author', - name='aliases', - field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None), - ), - ] diff --git a/bookwyrm/migrations/0013_book_origin_id.py b/bookwyrm/migrations/0013_book_origin_id.py new file mode 100644 index 000000000..581a2406e --- /dev/null +++ b/bookwyrm/migrations/0013_book_origin_id.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.7 on 2020-11-24 21:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0012_attachment'), + ] + + operations = [ + migrations.AlterField( + model_name='book', + name='origin_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/bookwyrm/migrations/0014_auto_20201128_0118.py b/bookwyrm/migrations/0014_auto_20201128_0118.py new file mode 100644 index 000000000..babdd7805 --- /dev/null +++ b/bookwyrm/migrations/0014_auto_20201128_0118.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.7 on 2020-11-28 01:18 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0013_book_origin_id'), + ] + + operations = [ + migrations.RenameModel( + old_name='Attachment', + new_name='Image', + ), + ] diff --git a/bookwyrm/migrations/0014_status_remote_id.py b/bookwyrm/migrations/0014_status_remote_id.py deleted file mode 100644 index fecc6ffcc..000000000 --- a/bookwyrm/migrations/0014_status_remote_id.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-10 19:04 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0013_user_manually_approves_followers'), - ] - - operations = [ - migrations.AddField( - model_name='status', - name='remote_id', - field=models.CharField(max_length=255, null=True, unique=True), - ), - ] diff --git a/bookwyrm/migrations/0015_auto_20200311_1212.py b/bookwyrm/migrations/0015_auto_20200311_1212.py deleted file mode 100644 index bc7a80d6a..000000000 --- a/bookwyrm/migrations/0015_auto_20200311_1212.py +++ /dev/null @@ -1,115 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-11 12:12 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0014_status_remote_id'), - ] - - operations = [ - migrations.CreateModel( - name='UserBlocks', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_date', models.DateTimeField(auto_now_add=True)), - ('updated_date', models.DateTimeField(auto_now=True)), - ('relationship_id', models.CharField(max_length=100)), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='UserFollowRequest', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_date', models.DateTimeField(auto_now_add=True)), - ('updated_date', models.DateTimeField(auto_now=True)), - ('relationship_id', models.CharField(max_length=100)), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='UserFollows', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_date', models.DateTimeField(auto_now_add=True)), - ('updated_date', models.DateTimeField(auto_now=True)), - ('relationship_id', models.CharField(max_length=100)), - ], - options={ - 'abstract': False, - }, - ), - migrations.RemoveField( - model_name='user', - name='followers', - ), - migrations.DeleteModel( - name='UserRelationship', - ), - migrations.AddField( - model_name='userfollows', - name='user_object', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollows_user_object', to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='userfollows', - name='user_subject', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollows_user_subject', to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='userfollowrequest', - name='user_object', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollowrequest_user_object', to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='userfollowrequest', - name='user_subject', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollowrequest_user_subject', to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='userblocks', - name='user_object', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userblocks_user_object', to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='userblocks', - name='user_subject', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userblocks_user_subject', to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='user', - name='blocks', - field=models.ManyToManyField(related_name='blocked_by', through='bookwyrm.UserBlocks', to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='user', - name='follow_requests', - field=models.ManyToManyField(related_name='follower_requests', through='bookwyrm.UserFollowRequest', to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='user', - name='following', - field=models.ManyToManyField(related_name='followers', through='bookwyrm.UserFollows', to=settings.AUTH_USER_MODEL), - ), - migrations.AddConstraint( - model_name='userfollows', - constraint=models.UniqueConstraint(fields=('user_subject', 'user_object'), name='userfollows_unique'), - ), - migrations.AddConstraint( - model_name='userfollowrequest', - constraint=models.UniqueConstraint(fields=('user_subject', 'user_object'), name='userfollowrequest_unique'), - ), - migrations.AddConstraint( - model_name='userblocks', - constraint=models.UniqueConstraint(fields=('user_subject', 'user_object'), name='userblocks_unique'), - ), - ] diff --git a/bookwyrm/migrations/0015_auto_20201128_0349.py b/bookwyrm/migrations/0015_auto_20201128_0349.py new file mode 100644 index 000000000..52b155186 --- /dev/null +++ b/bookwyrm/migrations/0015_auto_20201128_0349.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.7 on 2020-11-28 03:49 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0014_auto_20201128_0118'), + ] + + operations = [ + migrations.AlterField( + model_name='image', + name='status', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='bookwyrm.Status'), + ), + ] diff --git a/bookwyrm/migrations/0016_auto_20200313_1337.py b/bookwyrm/migrations/0016_auto_20200313_1337.py deleted file mode 100644 index c9b7015d3..000000000 --- a/bookwyrm/migrations/0016_auto_20200313_1337.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-13 13:37 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0015_auto_20200311_1212'), - ] - - operations = [ - migrations.AlterField( - model_name='notification', - name='notification_type', - field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request')], max_length=255), - ), - migrations.AddConstraint( - model_name='notification', - constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST']), name='notification_type_valid'), - ), - ] diff --git a/bookwyrm/migrations/0016_auto_20201129_0304.py b/bookwyrm/migrations/0016_auto_20201129_0304.py new file mode 100644 index 000000000..2bf820e18 --- /dev/null +++ b/bookwyrm/migrations/0016_auto_20201129_0304.py @@ -0,0 +1,63 @@ +# Generated by Django 3.0.7 on 2020-11-29 03:04 + +import bookwyrm.utils.fields +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0015_auto_20201128_0349'), + ] + + operations = [ + migrations.AlterField( + model_name='book', + name='subject_places', + field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None), + ), + migrations.AlterField( + model_name='book', + name='subjects', + field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None), + ), + migrations.AlterField( + model_name='edition', + name='parent_work', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='editions', to='bookwyrm.Work'), + ), + migrations.AlterField( + model_name='tag', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + migrations.AlterUniqueTogether( + name='tag', + unique_together=set(), + ), + migrations.RemoveField( + model_name='tag', + name='book', + ), + migrations.RemoveField( + model_name='tag', + name='user', + ), + migrations.CreateModel( + name='UserTag', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(auto_now_add=True)), + ('updated_date', models.DateTimeField(auto_now=True)), + ('remote_id', models.CharField(max_length=255, null=True)), + ('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition')), + ('tag', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Tag')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'book', 'tag')}, + }, + ), + ] diff --git a/bookwyrm/migrations/0016_auto_20201211_2026.py b/bookwyrm/migrations/0016_auto_20201211_2026.py new file mode 100644 index 000000000..46b6140c3 --- /dev/null +++ b/bookwyrm/migrations/0016_auto_20201211_2026.py @@ -0,0 +1,28 @@ +# Generated by Django 3.0.7 on 2020-12-11 20:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0015_auto_20201128_0349'), + ] + + operations = [ + migrations.AddField( + model_name='sitesettings', + name='admin_email', + field=models.EmailField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='sitesettings', + name='support_link', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='sitesettings', + name='support_title', + field=models.CharField(blank=True, max_length=100, null=True), + ), + ] diff --git a/bookwyrm/migrations/0017_auto_20200314_2152.py b/bookwyrm/migrations/0017_auto_20200314_2152.py deleted file mode 100644 index 39fd6407b..000000000 --- a/bookwyrm/migrations/0017_auto_20200314_2152.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-14 21:52 - -from django.db import migrations, models -import django.db.models.expressions - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0016_auto_20200313_1337'), - ] - - operations = [ - migrations.AddConstraint( - model_name='userblocks', - constraint=models.CheckConstraint(check=models.Q(_negated=True, user_subject=django.db.models.expressions.F('user_object')), name='userblocks_no_self'), - ), - migrations.AddConstraint( - model_name='userfollowrequest', - constraint=models.CheckConstraint(check=models.Q(_negated=True, user_subject=django.db.models.expressions.F('user_object')), name='userfollowrequest_no_self'), - ), - migrations.AddConstraint( - model_name='userfollows', - constraint=models.CheckConstraint(check=models.Q(_negated=True, user_subject=django.db.models.expressions.F('user_object')), name='userfollows_no_self'), - ), - ] diff --git a/bookwyrm/migrations/0017_auto_20201130_1819.py b/bookwyrm/migrations/0017_auto_20201130_1819.py new file mode 100644 index 000000000..ce9f1cc7b --- /dev/null +++ b/bookwyrm/migrations/0017_auto_20201130_1819.py @@ -0,0 +1,189 @@ +# Generated by Django 3.0.7 on 2020-11-30 18:19 + +import bookwyrm.models.base_model +import bookwyrm.models.fields +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + +def copy_rsa_keys(app_registry, schema_editor): + db_alias = schema_editor.connection.alias + users = app_registry.get_model('bookwyrm', 'User') + keypair = app_registry.get_model('bookwyrm', 'KeyPair') + for user in users.objects.using(db_alias): + if user.public_key or user.private_key: + user.key_pair = keypair.objects.create( + remote_id='%s/#main-key' % user.remote_id, + private_key=user.private_key, + public_key=user.public_key + ) + user.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0016_auto_20201129_0304'), + ] + operations = [ + migrations.CreateModel( + name='KeyPair', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(auto_now_add=True)), + ('updated_date', models.DateTimeField(auto_now=True)), + ('remote_id', bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id])), + ('private_key', models.TextField(blank=True, null=True)), + ('public_key', bookwyrm.models.fields.TextField(blank=True, null=True)), + ], + options={ + 'abstract': False, + }, + bases=(bookwyrm.models.base_model.ActivitypubMixin, models.Model), + ), + migrations.AddField( + model_name='user', + name='followers', + field=bookwyrm.models.fields.ManyToManyField(related_name='following', through='bookwyrm.UserFollows', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='author', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='book', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='connector', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='favorite', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='federatedserver', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='image', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='notification', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='readthrough', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='shelf', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='shelfbook', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='status', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='tag', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='user', + name='avatar', + field=bookwyrm.models.fields.ImageField(blank=True, null=True, upload_to='avatars/'), + ), + migrations.AlterField( + model_name='user', + name='bookwyrm_user', + field=bookwyrm.models.fields.BooleanField(default=True), + ), + migrations.AlterField( + model_name='user', + name='inbox', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, unique=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='user', + name='local', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='user', + name='manually_approves_followers', + field=bookwyrm.models.fields.BooleanField(default=False), + ), + migrations.AlterField( + model_name='user', + name='name', + field=bookwyrm.models.fields.CharField(blank=True, max_length=100, null=True), + ), + migrations.AlterField( + model_name='user', + name='outbox', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, unique=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='user', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, unique=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='user', + name='shared_inbox', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='user', + name='summary', + field=bookwyrm.models.fields.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='user', + name='username', + field=bookwyrm.models.fields.UsernameField(), + ), + migrations.AlterField( + model_name='userblocks', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='userfollowrequest', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='userfollows', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='usertag', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AddField( + model_name='user', + name='key_pair', + field=bookwyrm.models.fields.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owner', to='bookwyrm.KeyPair'), + ), + migrations.RunPython(copy_rsa_keys), + ] diff --git a/bookwyrm/migrations/0018_auto_20201130_1832.py b/bookwyrm/migrations/0018_auto_20201130_1832.py new file mode 100644 index 000000000..278446cf5 --- /dev/null +++ b/bookwyrm/migrations/0018_auto_20201130_1832.py @@ -0,0 +1,25 @@ +# Generated by Django 3.0.7 on 2020-11-30 18:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0017_auto_20201130_1819'), + ] + + operations = [ + migrations.RemoveField( + model_name='user', + name='following', + ), + migrations.RemoveField( + model_name='user', + name='private_key', + ), + migrations.RemoveField( + model_name='user', + name='public_key', + ), + ] diff --git a/bookwyrm/migrations/0018_favorite_remote_id.py b/bookwyrm/migrations/0018_favorite_remote_id.py deleted file mode 100644 index 7ddb7e37c..000000000 --- a/bookwyrm/migrations/0018_favorite_remote_id.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-21 21:44 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0017_auto_20200314_2152'), - ] - - operations = [ - migrations.AddField( - model_name='favorite', - name='remote_id', - field=models.CharField(max_length=255, null=True, unique=True), - ), - ] diff --git a/bookwyrm/migrations/0019_auto_20201130_1939.py b/bookwyrm/migrations/0019_auto_20201130_1939.py new file mode 100644 index 000000000..11cf6a3b6 --- /dev/null +++ b/bookwyrm/migrations/0019_auto_20201130_1939.py @@ -0,0 +1,36 @@ +# Generated by Django 3.0.7 on 2020-11-30 19:39 + +import bookwyrm.models.fields +from django.db import migrations + +def update_notnull(app_registry, schema_editor): + db_alias = schema_editor.connection.alias + users = app_registry.get_model('bookwyrm', 'User') + for user in users.objects.using(db_alias): + if user.name and user.summary: + continue + if not user.summary: + user.summary = '' + if not user.name: + user.name = '' + user.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0018_auto_20201130_1832'), + ] + + operations = [ + migrations.RunPython(update_notnull), + migrations.AlterField( + model_name='user', + name='name', + field=bookwyrm.models.fields.CharField(default='', max_length=100), + ), + migrations.AlterField( + model_name='user', + name='summary', + field=bookwyrm.models.fields.TextField(default=''), + ), + ] diff --git a/bookwyrm/migrations/0019_comment.py b/bookwyrm/migrations/0019_comment.py deleted file mode 100644 index a639ca992..000000000 --- a/bookwyrm/migrations/0019_comment.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-21 22:43 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0018_favorite_remote_id'), - ] - - operations = [ - migrations.CreateModel( - name='Comment', - fields=[ - ('status_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Status')), - ('name', models.CharField(max_length=255)), - ('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Book')), - ], - options={ - 'abstract': False, - }, - bases=('bookwyrm.status',), - ), - ] diff --git a/bookwyrm/migrations/0020_auto_20200327_2335.py b/bookwyrm/migrations/0020_auto_20200327_2335.py deleted file mode 100644 index ef8185376..000000000 --- a/bookwyrm/migrations/0020_auto_20200327_2335.py +++ /dev/null @@ -1,58 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-27 23:35 - -from django.db import migrations, models -import django.db.models.deletion -import bookwyrm.models.book - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0019_comment'), - ] - - operations = [ - migrations.CreateModel( - name='Connector', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_date', models.DateTimeField(auto_now_add=True)), - ('updated_date', models.DateTimeField(auto_now=True)), - ('identifier', models.CharField(max_length=255, unique=True)), - ('connector_file', models.CharField(choices=[('openlibrary', 'Openlibrary'), ('bookwyrm', 'BookWyrm')], default='openlibrary', max_length=255)), - ('is_self', models.BooleanField(default=False)), - ('api_key', models.CharField(max_length=255, null=True)), - ('base_url', models.CharField(max_length=255)), - ('covers_url', models.CharField(max_length=255)), - ('search_url', models.CharField(max_length=255, null=True)), - ('key_name', models.CharField(max_length=255)), - ('politeness_delay', models.IntegerField(null=True)), - ('max_query_count', models.IntegerField(null=True)), - ('query_count', models.IntegerField(default=0)), - ('query_count_expiry', models.DateTimeField(auto_now_add=True)), - ], - ), - migrations.RenameField( - model_name='book', - old_name='local_key', - new_name='fedireads_key', - ), - migrations.RenameField( - model_name='book', - old_name='origin', - new_name='source_url', - ), - migrations.RemoveField( - model_name='book', - name='local_edits', - ), - migrations.AddConstraint( - model_name='connector', - constraint=models.CheckConstraint(check=models.Q(connector_file__in=bookwyrm.models.connector.ConnectorFiles), name='connector_file_valid'), - ), - migrations.AddField( - model_name='book', - name='connector', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Connector'), - ), - ] diff --git a/bookwyrm/migrations/0020_auto_20201208_0213.py b/bookwyrm/migrations/0020_auto_20201208_0213.py new file mode 100644 index 000000000..9c5345c75 --- /dev/null +++ b/bookwyrm/migrations/0020_auto_20201208_0213.py @@ -0,0 +1,353 @@ +# Generated by Django 3.0.7 on 2020-12-08 02:13 + +import bookwyrm.models.fields +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0019_auto_20201130_1939'), + ] + + operations = [ + migrations.AlterField( + model_name='author', + name='aliases', + field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None), + ), + migrations.AlterField( + model_name='author', + name='bio', + field=bookwyrm.models.fields.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='author', + name='born', + field=bookwyrm.models.fields.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='author', + name='died', + field=bookwyrm.models.fields.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='author', + name='name', + field=bookwyrm.models.fields.CharField(max_length=255), + ), + migrations.AlterField( + model_name='author', + name='openlibrary_key', + field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='author', + name='wikipedia_link', + field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='book', + name='authors', + field=bookwyrm.models.fields.ManyToManyField(to='bookwyrm.Author'), + ), + migrations.AlterField( + model_name='book', + name='cover', + field=bookwyrm.models.fields.ImageField(blank=True, null=True, upload_to='covers/'), + ), + migrations.AlterField( + model_name='book', + name='description', + field=bookwyrm.models.fields.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='book', + name='first_published_date', + field=bookwyrm.models.fields.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='book', + name='goodreads_key', + field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='book', + name='languages', + field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None), + ), + migrations.AlterField( + model_name='book', + name='librarything_key', + field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='book', + name='openlibrary_key', + field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='book', + name='published_date', + field=bookwyrm.models.fields.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='book', + name='series', + field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='book', + name='series_number', + field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='book', + name='sort_title', + field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='book', + name='subject_places', + field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None), + ), + migrations.AlterField( + model_name='book', + name='subjects', + field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None), + ), + migrations.AlterField( + model_name='book', + name='subtitle', + field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='book', + name='title', + field=bookwyrm.models.fields.CharField(max_length=255), + ), + migrations.AlterField( + model_name='boost', + name='boosted_status', + field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='boosters', to='bookwyrm.Status'), + ), + migrations.AlterField( + model_name='comment', + name='book', + field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), + ), + migrations.AlterField( + model_name='edition', + name='asin', + field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='edition', + name='isbn_10', + field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='edition', + name='isbn_13', + field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='edition', + name='oclc_number', + field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='edition', + name='pages', + field=bookwyrm.models.fields.IntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='edition', + name='parent_work', + field=bookwyrm.models.fields.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='editions', to='bookwyrm.Work'), + ), + migrations.AlterField( + model_name='edition', + name='physical_format', + field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='edition', + name='publishers', + field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None), + ), + migrations.AlterField( + model_name='favorite', + name='status', + field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Status'), + ), + migrations.AlterField( + model_name='favorite', + name='user', + field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='image', + name='caption', + field=bookwyrm.models.fields.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='image', + name='image', + field=bookwyrm.models.fields.ImageField(blank=True, null=True, upload_to='status/'), + ), + migrations.AlterField( + model_name='quotation', + name='book', + field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), + ), + migrations.AlterField( + model_name='quotation', + name='quote', + field=bookwyrm.models.fields.TextField(), + ), + migrations.AlterField( + model_name='review', + name='book', + field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), + ), + migrations.AlterField( + model_name='review', + name='name', + field=bookwyrm.models.fields.CharField(max_length=255, null=True), + ), + migrations.AlterField( + model_name='review', + name='rating', + field=bookwyrm.models.fields.IntegerField(blank=True, default=None, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)]), + ), + migrations.AlterField( + model_name='shelf', + name='name', + field=bookwyrm.models.fields.CharField(max_length=100), + ), + migrations.AlterField( + model_name='shelf', + name='privacy', + field=bookwyrm.models.fields.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255), + ), + migrations.AlterField( + model_name='shelf', + name='user', + field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='shelfbook', + name='added_by', + field=bookwyrm.models.fields.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='shelfbook', + name='book', + field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), + ), + migrations.AlterField( + model_name='shelfbook', + name='shelf', + field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Shelf'), + ), + migrations.AlterField( + model_name='status', + name='content', + field=bookwyrm.models.fields.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='status', + name='mention_books', + field=bookwyrm.models.fields.TagField(related_name='mention_book', to='bookwyrm.Edition'), + ), + migrations.AlterField( + model_name='status', + name='mention_users', + field=bookwyrm.models.fields.TagField(related_name='mention_user', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='status', + name='published_date', + field=bookwyrm.models.fields.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AlterField( + model_name='status', + name='reply_parent', + field=bookwyrm.models.fields.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Status'), + ), + migrations.AlterField( + model_name='status', + name='sensitive', + field=bookwyrm.models.fields.BooleanField(default=False), + ), + migrations.AlterField( + model_name='status', + name='user', + field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='tag', + name='name', + field=bookwyrm.models.fields.CharField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='userblocks', + name='user_object', + field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userblocks_user_object', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='userblocks', + name='user_subject', + field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userblocks_user_subject', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='userfollowrequest', + name='user_object', + field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollowrequest_user_object', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='userfollowrequest', + name='user_subject', + field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollowrequest_user_subject', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='userfollows', + name='user_object', + field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollows_user_object', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='userfollows', + name='user_subject', + field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollows_user_subject', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='usertag', + name='book', + field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), + ), + migrations.AlterField( + model_name='usertag', + name='tag', + field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Tag'), + ), + migrations.AlterField( + model_name='usertag', + name='user', + field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='work', + name='default_edition', + field=bookwyrm.models.fields.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), + ), + migrations.AlterField( + model_name='work', + name='lccn', + field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/bookwyrm/migrations/0021_auto_20200328_0428.py b/bookwyrm/migrations/0021_auto_20200328_0428.py deleted file mode 100644 index 8587c5081..000000000 --- a/bookwyrm/migrations/0021_auto_20200328_0428.py +++ /dev/null @@ -1,44 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-28 04:28 - -from django.db import migrations, models -import bookwyrm.utils.fields - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0020_auto_20200327_2335'), - ] - - operations = [ - migrations.AddField( - model_name='book', - name='goodreads_key', - field=models.CharField(max_length=255, null=True, unique=True), - ), - migrations.AddField( - model_name='book', - name='subject_places', - field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None), - ), - migrations.AddField( - model_name='book', - name='subjects', - field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None), - ), - migrations.AddField( - model_name='edition', - name='physical_format', - field=models.CharField(max_length=255, null=True), - ), - migrations.AddField( - model_name='edition', - name='publishers', - field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None), - ), - migrations.AlterField( - model_name='connector', - name='connector_file', - field=models.CharField(choices=[('openlibrary', 'Openlibrary'), ('fedireads_connector', 'Fedireads Connector')], default='openlibrary', max_length=255), - ), - ] diff --git a/bookwyrm/migrations/0021_merge_20201212_1737.py b/bookwyrm/migrations/0021_merge_20201212_1737.py new file mode 100644 index 000000000..4ccf8c8cc --- /dev/null +++ b/bookwyrm/migrations/0021_merge_20201212_1737.py @@ -0,0 +1,14 @@ +# Generated by Django 3.0.7 on 2020-12-12 17:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0020_auto_20201208_0213'), + ('bookwyrm', '0016_auto_20201211_2026'), + ] + + operations = [ + ] diff --git a/bookwyrm/migrations/0022_auto_20200328_2001.py b/bookwyrm/migrations/0022_auto_20200328_2001.py deleted file mode 100644 index 188078c8d..000000000 --- a/bookwyrm/migrations/0022_auto_20200328_2001.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-28 20:01 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0021_auto_20200328_0428'), - ] - - operations = [ - migrations.RemoveField( - model_name='connector', - name='is_self', - ), - migrations.AddField( - model_name='author', - name='fedireads_key', - field=models.CharField(max_length=255, null=True, unique=True), - ), - migrations.AlterField( - model_name='connector', - name='connector_file', - field=models.CharField(choices=[('openlibrary', 'Openlibrary'), ('self_connector', 'Self Connector'), ('fedireads_connector', 'Fedireads Connector')], default='openlibrary', max_length=255), - ), - ] diff --git a/bookwyrm/migrations/0022_auto_20201212_1744.py b/bookwyrm/migrations/0022_auto_20201212_1744.py new file mode 100644 index 000000000..0a98597f8 --- /dev/null +++ b/bookwyrm/migrations/0022_auto_20201212_1744.py @@ -0,0 +1,30 @@ +# Generated by Django 3.0.7 on 2020-12-12 17:44 + +from django.db import migrations + + +def set_author_name(app_registry, schema_editor): + db_alias = schema_editor.connection.alias + authors = app_registry.get_model('bookwyrm', 'Author') + for author in authors.objects.using(db_alias): + if not author.name: + author.name = '%s %s' % (author.first_name, author.last_name) + author.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0021_merge_20201212_1737'), + ] + + operations = [ + migrations.RunPython(set_author_name), + migrations.RemoveField( + model_name='author', + name='first_name', + ), + migrations.RemoveField( + model_name='author', + name='last_name', + ), + ] diff --git a/bookwyrm/migrations/0023_auto_20200328_2203.py b/bookwyrm/migrations/0023_auto_20200328_2203.py deleted file mode 100644 index 85416e727..000000000 --- a/bookwyrm/migrations/0023_auto_20200328_2203.py +++ /dev/null @@ -1,114 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-28 22:03 - -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0022_auto_20200328_2001'), - ] - - operations = [ - migrations.AddField( - model_name='book', - name='sync_cover', - field=models.BooleanField(default=True), - ), - migrations.AlterField( - model_name='author', - name='born', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AlterField( - model_name='author', - name='died', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AlterField( - model_name='author', - name='fedireads_key', - field=models.CharField(default=uuid.uuid4, max_length=255, unique=True), - ), - migrations.AlterField( - model_name='author', - name='first_name', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AlterField( - model_name='author', - name='last_name', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AlterField( - model_name='author', - name='openlibrary_key', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AlterField( - model_name='book', - name='first_published_date', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AlterField( - model_name='book', - name='goodreads_key', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AlterField( - model_name='book', - name='language', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AlterField( - model_name='book', - name='librarything_key', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AlterField( - model_name='book', - name='openlibrary_key', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AlterField( - model_name='book', - name='published_date', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AlterField( - model_name='book', - name='sort_title', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AlterField( - model_name='book', - name='subtitle', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AlterField( - model_name='edition', - name='isbn', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AlterField( - model_name='edition', - name='oclc_number', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AlterField( - model_name='edition', - name='pages', - field=models.IntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='edition', - name='physical_format', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AlterField( - model_name='work', - name='lccn', - field=models.CharField(blank=True, max_length=255, null=True), - ), - ] diff --git a/bookwyrm/migrations/0024_federatedserver_application_version.py b/bookwyrm/migrations/0024_federatedserver_application_version.py deleted file mode 100644 index 10e78c233..000000000 --- a/bookwyrm/migrations/0024_federatedserver_application_version.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-29 22:44 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0023_auto_20200328_2203'), - ] - - operations = [ - migrations.AddField( - model_name='federatedserver', - name='application_version', - field=models.CharField(max_length=255, null=True), - ), - ] diff --git a/bookwyrm/migrations/0025_auto_20200330_0037.py b/bookwyrm/migrations/0025_auto_20200330_0037.py deleted file mode 100644 index 5477e3775..000000000 --- a/bookwyrm/migrations/0025_auto_20200330_0037.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-30 00:37 - -from django.db import migrations, models -import django.utils.timezone - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0024_federatedserver_application_version'), - ] - - operations = [ - migrations.AlterField( - model_name='book', - name='last_sync_date', - field=models.DateTimeField(default=django.utils.timezone.now), - ), - migrations.AlterField( - model_name='status', - name='published_date', - field=models.DateTimeField(default=django.utils.timezone.now), - ), - ] diff --git a/bookwyrm/migrations/0026_auto_20200330_1456.py b/bookwyrm/migrations/0026_auto_20200330_1456.py deleted file mode 100644 index 7ca7cb34d..000000000 --- a/bookwyrm/migrations/0026_auto_20200330_1456.py +++ /dev/null @@ -1,42 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-30 14:56 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0025_auto_20200330_0037'), - ] - - operations = [ - migrations.CreateModel( - name='Boost', - fields=[ - ('status_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Status')), - ], - options={ - 'abstract': False, - }, - bases=('bookwyrm.status',), - ), - migrations.RemoveConstraint( - model_name='notification', - name='notification_type_valid', - ), - migrations.AlterField( - model_name='notification', - name='notification_type', - field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request'), ('BOOST', 'Boost')], max_length=255), - ), - migrations.AddConstraint( - model_name='notification', - constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST', 'BOOST']), name='notification_type_valid'), - ), - migrations.AddField( - model_name='boost', - name='boosted_status', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='boosters', to='bookwyrm.Status'), - ), - ] diff --git a/bookwyrm/migrations/0027_auto_20200330_2232.py b/bookwyrm/migrations/0027_auto_20200330_2232.py deleted file mode 100644 index 2dc1af24b..000000000 --- a/bookwyrm/migrations/0027_auto_20200330_2232.py +++ /dev/null @@ -1,82 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-30 22:32 - -from django.db import migrations, models -import django.db.models.deletion -import bookwyrm.utils.fields - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0026_auto_20200330_1456'), - ] - - operations = [ - migrations.RemoveField( - model_name='book', - name='language', - ), - migrations.RemoveField( - model_name='book', - name='parent_work', - ), - migrations.RemoveField( - model_name='book', - name='shelves', - ), - migrations.AddField( - model_name='book', - name='languages', - field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None), - ), - migrations.AddField( - model_name='edition', - name='default', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='edition', - name='parent_work', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Work'), - ), - migrations.AddField( - model_name='edition', - name='shelves', - field=models.ManyToManyField(through='bookwyrm.ShelfBook', to='bookwyrm.Shelf'), - ), - migrations.AlterField( - model_name='comment', - name='book', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), - ), - migrations.AlterField( - model_name='notification', - name='related_book', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), - ), - migrations.AlterField( - model_name='review', - name='book', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), - ), - migrations.AlterField( - model_name='shelf', - name='books', - field=models.ManyToManyField(through='bookwyrm.ShelfBook', to='bookwyrm.Edition'), - ), - migrations.AlterField( - model_name='shelfbook', - name='book', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), - ), - migrations.AlterField( - model_name='status', - name='mention_books', - field=models.ManyToManyField(related_name='mention_book', to='bookwyrm.Edition'), - ), - migrations.AlterField( - model_name='tag', - name='book', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), - ), - ] diff --git a/bookwyrm/migrations/0028_auto_20200401_1824.py b/bookwyrm/migrations/0028_auto_20200401_1824.py deleted file mode 100644 index 3421f3536..000000000 --- a/bookwyrm/migrations/0028_auto_20200401_1824.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-01 18:24 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0027_auto_20200330_2232'), - ] - - operations = [ - migrations.RemoveField( - model_name='comment', - name='name', - ), - migrations.AlterField( - model_name='review', - name='rating', - field=models.IntegerField(blank=True, default=None, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)]), - ), - ] diff --git a/bookwyrm/migrations/0029_auto_20200403_1835.py b/bookwyrm/migrations/0029_auto_20200403_1835.py deleted file mode 100644 index c3f2a659a..000000000 --- a/bookwyrm/migrations/0029_auto_20200403_1835.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-03 18:35 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0028_auto_20200401_1824'), - ] - - operations = [ - migrations.AlterField( - model_name='review', - name='name', - field=models.CharField(max_length=255, null=True), - ), - ] diff --git a/bookwyrm/migrations/0030_quotation.py b/bookwyrm/migrations/0030_quotation.py deleted file mode 100644 index 3a0c38d27..000000000 --- a/bookwyrm/migrations/0030_quotation.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-07 00:51 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0029_auto_20200403_1835'), - ] - - operations = [ - migrations.CreateModel( - name='Quotation', - fields=[ - ('status_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Status')), - ('quote', models.TextField()), - ('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition')), - ], - options={ - 'abstract': False, - }, - bases=('bookwyrm.status',), - ), - ] diff --git a/bookwyrm/migrations/0031_readthrough.py b/bookwyrm/migrations/0031_readthrough.py deleted file mode 100644 index 9b9a6ffcb..000000000 --- a/bookwyrm/migrations/0031_readthrough.py +++ /dev/null @@ -1,31 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-15 12:24 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0030_quotation'), - ] - - operations = [ - migrations.CreateModel( - name='ReadThrough', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_date', models.DateTimeField(auto_now_add=True)), - ('updated_date', models.DateTimeField(auto_now=True)), - ('pages_read', models.IntegerField(blank=True, null=True)), - ('start_date', models.DateTimeField(blank=True, null=True)), - ('finish_date', models.DateTimeField(blank=True, null=True)), - ('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Book')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/bookwyrm/migrations/0032_auto_20200421_1347.py b/bookwyrm/migrations/0032_auto_20200421_1347.py deleted file mode 100644 index 08e74c18a..000000000 --- a/bookwyrm/migrations/0032_auto_20200421_1347.py +++ /dev/null @@ -1,60 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-21 13:47 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone -import bookwyrm.utils.fields - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0031_readthrough'), - ] - - operations = [ - migrations.CreateModel( - name='ImportItem', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('data', bookwyrm.utils.fields.JSONField()), - ], - ), - migrations.CreateModel( - name='ImportJob', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_date', models.DateTimeField(default=django.utils.timezone.now)), - ('task_id', models.CharField(max_length=100, null=True)), - ], - ), - migrations.RemoveConstraint( - model_name='notification', - name='notification_type_valid', - ), - migrations.AlterField( - model_name='notification', - name='notification_type', - field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request'), ('BOOST', 'Boost'), ('IMPORT_RESULT', 'Import Result')], max_length=255), - ), - migrations.AddConstraint( - model_name='notification', - constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST', 'BOOST', 'IMPORT_RESULT']), name='notification_type_valid'), - ), - migrations.AddField( - model_name='importjob', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='importitem', - name='book', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='bookwyrm.Book'), - ), - migrations.AddField( - model_name='importitem', - name='job', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='bookwyrm.ImportJob'), - ), - ] diff --git a/bookwyrm/migrations/0033_auto_20200422_1249.py b/bookwyrm/migrations/0033_auto_20200422_1249.py deleted file mode 100644 index 6d1eea83f..000000000 --- a/bookwyrm/migrations/0033_auto_20200422_1249.py +++ /dev/null @@ -1,43 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-22 12:49 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0032_auto_20200421_1347'), - ] - - operations = [ - migrations.RemoveConstraint( - model_name='notification', - name='notification_type_valid', - ), - migrations.AddField( - model_name='importitem', - name='fail_reason', - field=models.TextField(null=True), - ), - migrations.AddField( - model_name='importitem', - name='index', - field=models.IntegerField(default=1), - preserve_default=False, - ), - migrations.AddField( - model_name='notification', - name='related_import', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.ImportJob'), - ), - migrations.AlterField( - model_name='notification', - name='notification_type', - field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request'), ('BOOST', 'Boost'), ('IMPORT', 'Import')], max_length=255), - ), - migrations.AddConstraint( - model_name='notification', - constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST', 'BOOST', 'IMPORT']), name='notification_type_valid'), - ), - ] diff --git a/bookwyrm/migrations/0034_importjob_import_status.py b/bookwyrm/migrations/0034_importjob_import_status.py deleted file mode 100644 index 678c4f9c9..000000000 --- a/bookwyrm/migrations/0034_importjob_import_status.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-22 13:12 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0033_auto_20200422_1249'), - ] - - operations = [ - migrations.AddField( - model_name='importjob', - name='import_status', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Status'), - ), - ] diff --git a/bookwyrm/migrations/0035_auto_20200429_1708.py b/bookwyrm/migrations/0035_auto_20200429_1708.py deleted file mode 100644 index 3c9e78d43..000000000 --- a/bookwyrm/migrations/0035_auto_20200429_1708.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 3.0.3 on 2020-04-29 17:08 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0034_importjob_import_status'), - ] - - operations = [ - migrations.RenameField( - model_name='edition', - old_name='isbn', - new_name='isbn_13', - ), - migrations.AddField( - model_name='book', - name='author_text', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AddField( - model_name='edition', - name='asin', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AddField( - model_name='edition', - name='isbn_10', - field=models.CharField(blank=True, max_length=255, null=True), - ), - ] diff --git a/bookwyrm/migrations/0036_auto_20200503_2007.py b/bookwyrm/migrations/0036_auto_20200503_2007.py deleted file mode 100644 index 6fb2fe623..000000000 --- a/bookwyrm/migrations/0036_auto_20200503_2007.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 3.0.3 on 2020-05-03 20:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0035_auto_20200429_1708'), - ] - - operations = [ - migrations.AddField( - model_name='connector', - name='books_url', - field=models.CharField(default='https://openlibrary.org', max_length=255), - preserve_default=False, - ), - migrations.AddField( - model_name='connector', - name='local', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='connector', - name='name', - field=models.CharField(max_length=255, null=True), - ), - migrations.AddField( - model_name='connector', - name='priority', - field=models.IntegerField(default=2), - ), - migrations.AlterField( - model_name='connector', - name='connector_file', - field=models.CharField(choices=[('openlibrary', 'Openlibrary'), ('self_connector', 'Self Connector'), ('fedireads_connector', 'Fedireads Connector')], max_length=255), - ), - ] diff --git a/bookwyrm/migrations/0037_auto_20200504_0154.py b/bookwyrm/migrations/0037_auto_20200504_0154.py deleted file mode 100644 index dfcf41ad7..000000000 --- a/bookwyrm/migrations/0037_auto_20200504_0154.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 3.0.3 on 2020-05-04 01:54 - -from django.db import migrations, models -import django.utils.timezone - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0036_auto_20200503_2007'), - ] - - operations = [ - migrations.RemoveField( - model_name='author', - name='fedireads_key', - ), - migrations.RemoveField( - model_name='book', - name='fedireads_key', - ), - migrations.RemoveField( - model_name='book', - name='source_url', - ), - migrations.AddField( - model_name='author', - name='last_sync_date', - field=models.DateTimeField(default=django.utils.timezone.now), - ), - migrations.AddField( - model_name='author', - name='sync', - field=models.BooleanField(default=True), - ), - migrations.AddField( - model_name='book', - name='remote_id', - field=models.CharField(max_length=255, null=True), - ), - ] diff --git a/bookwyrm/migrations/0038_author_remote_id.py b/bookwyrm/migrations/0038_author_remote_id.py deleted file mode 100644 index 24f20d5ed..000000000 --- a/bookwyrm/migrations/0038_author_remote_id.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-05-09 19:27 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0037_auto_20200504_0154'), - ] - - operations = [ - migrations.AddField( - model_name='author', - name='remote_id', - field=models.CharField(max_length=255, null=True), - ), - ] diff --git a/bookwyrm/migrations/0039_auto_20200510_2342.py b/bookwyrm/migrations/0039_auto_20200510_2342.py deleted file mode 100644 index 3e6048813..000000000 --- a/bookwyrm/migrations/0039_auto_20200510_2342.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.0.3 on 2020-05-10 23:42 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0038_author_remote_id'), - ] - - operations = [ - migrations.RemoveField( - model_name='book', - name='misc_identifiers', - ), - migrations.RemoveField( - model_name='connector', - name='key_name', - ), - ] diff --git a/bookwyrm/migrations/0040_auto_20200513_0153.py b/bookwyrm/migrations/0040_auto_20200513_0153.py deleted file mode 100644 index 04953c429..000000000 --- a/bookwyrm/migrations/0040_auto_20200513_0153.py +++ /dev/null @@ -1,77 +0,0 @@ -# Generated by Django 3.0.3 on 2020-05-13 01:53 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0039_auto_20200510_2342'), - ] - - operations = [ - migrations.RemoveField( - model_name='user', - name='actor', - ), - migrations.AddField( - model_name='connector', - name='remote_id', - field=models.CharField(max_length=255, null=True), - ), - migrations.AddField( - model_name='federatedserver', - name='remote_id', - field=models.CharField(max_length=255, null=True), - ), - migrations.AddField( - model_name='notification', - name='remote_id', - field=models.CharField(max_length=255, null=True), - ), - migrations.AddField( - model_name='readthrough', - name='remote_id', - field=models.CharField(max_length=255, null=True), - ), - migrations.AddField( - model_name='shelf', - name='remote_id', - field=models.CharField(max_length=255, null=True), - ), - migrations.AddField( - model_name='shelfbook', - name='remote_id', - field=models.CharField(max_length=255, null=True), - ), - migrations.AddField( - model_name='tag', - name='remote_id', - field=models.CharField(max_length=255, null=True), - ), - migrations.AddField( - model_name='userblocks', - name='remote_id', - field=models.CharField(max_length=255, null=True), - ), - migrations.AddField( - model_name='userfollowrequest', - name='remote_id', - field=models.CharField(max_length=255, null=True), - ), - migrations.AddField( - model_name='userfollows', - name='remote_id', - field=models.CharField(max_length=255, null=True), - ), - migrations.AlterField( - model_name='favorite', - name='remote_id', - field=models.CharField(max_length=255, null=True), - ), - migrations.AlterField( - model_name='status', - name='remote_id', - field=models.CharField(max_length=255, null=True), - ), - ] diff --git a/bookwyrm/migrations/0041_user_remote_id.py b/bookwyrm/migrations/0041_user_remote_id.py deleted file mode 100644 index 4bc4021f9..000000000 --- a/bookwyrm/migrations/0041_user_remote_id.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-05-13 02:23 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0040_auto_20200513_0153'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='remote_id', - field=models.CharField(max_length=255, null=True, unique=True), - ), - ] diff --git a/bookwyrm/migrations/0042_auto_20200524_0346.py b/bookwyrm/migrations/0042_auto_20200524_0346.py deleted file mode 100644 index 8453a5e6f..000000000 --- a/bookwyrm/migrations/0042_auto_20200524_0346.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.0.3 on 2020-05-24 03:46 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0041_user_remote_id'), - ] - - operations = [ - migrations.RemoveField( - model_name='status', - name='activity_type', - ), - migrations.RemoveField( - model_name='status', - name='status_type', - ), - ] diff --git a/bookwyrm/migrations/0042_sitesettings.py b/bookwyrm/migrations/0042_sitesettings.py deleted file mode 100644 index 4a0db2825..000000000 --- a/bookwyrm/migrations/0042_sitesettings.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.0.3 on 2020-06-01 18:53 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0041_user_remote_id'), - ] - - operations = [ - migrations.CreateModel( - name='SiteSettings', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(default='wyrms.cthulahoops.org', max_length=100)), - ('instance_description', models.TextField(default='This instance has no description.')), - ('code_of_conduct', models.TextField(default='Add a code of conduct here.')), - ('allow_registration', models.BooleanField(default=True)), - ], - ), - ] diff --git a/bookwyrm/migrations/0043_siteinvite.py b/bookwyrm/migrations/0043_siteinvite.py deleted file mode 100644 index d58650632..000000000 --- a/bookwyrm/migrations/0043_siteinvite.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 3.0.3 on 2020-06-01 21:31 - -from django.db import migrations, models -import bookwyrm.models.site - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0042_sitesettings'), - ] - - operations = [ - migrations.CreateModel( - name='SiteInvite', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('code', models.CharField(default=bookwyrm.models.site.new_access_code, max_length=32)), - ('expiry', models.DateTimeField(blank=True, null=True)), - ('use_limit', models.IntegerField(blank=True, null=True)), - ('times_used', models.IntegerField(default=0)), - ], - ), - ] diff --git a/bookwyrm/migrations/0045_merge_20200810_2010.py b/bookwyrm/migrations/0045_merge_20200810_2010.py deleted file mode 100644 index d93921c67..000000000 --- a/bookwyrm/migrations/0045_merge_20200810_2010.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 3.0.7 on 2020-08-10 20:10 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0044_siteinvite_user'), - ('bookwyrm', '0042_auto_20200524_0346'), - ] - - operations = [ - ] diff --git a/bookwyrm/migrations/0046_auto_20200921_1509.py b/bookwyrm/migrations/0046_auto_20200921_1509.py deleted file mode 100644 index beec59818..000000000 --- a/bookwyrm/migrations/0046_auto_20200921_1509.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 3.0.7 on 2020-09-21 15:09 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0045_merge_20200810_2010'), - ] - - operations = [ - migrations.RenameField( - model_name='user', - old_name='fedireads_user', - new_name='bookwyrm_user', - ), - migrations.AlterField( - model_name='connector', - name='connector_file', - field=models.CharField(choices=[('openlibrary', 'Openlibrary'), ('self_connector', 'Self Connector'), ('bookwyrm_connector', 'BookWyrm Connector')], max_length=255), - ), - migrations.AlterField( - model_name='sitesettings', - name='name', - field=models.CharField(default='1d8390fd.ngrok.io', max_length=100), - ), - ] diff --git a/bookwyrm/migrations/0047_auto_20200928_2312.py b/bookwyrm/migrations/0047_auto_20200928_2312.py deleted file mode 100644 index 73ce39371..000000000 --- a/bookwyrm/migrations/0047_auto_20200928_2312.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.7 on 2020-09-28 23:12 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0046_auto_20200921_1509'), - ] - - operations = [ - migrations.AlterField( - model_name='connector', - name='connector_file', - field=models.CharField(choices=[('openlibrary', 'Openlibrary'), ('self_connector', 'Self Connector'), ('bookwyrm_connector', 'Bookwyrm Connector')], max_length=255), - ), - ] diff --git a/bookwyrm/migrations/0048_generatednote.py b/bookwyrm/migrations/0048_generatednote.py deleted file mode 100644 index 09fcef9fb..000000000 --- a/bookwyrm/migrations/0048_generatednote.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 3.0.7 on 2020-09-29 00:22 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0047_auto_20200928_2312'), - ] - - operations = [ - migrations.CreateModel( - name='GeneratedStatus', - fields=[ - ('status_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Status')), - ], - options={ - 'abstract': False, - }, - bases=('bookwyrm.status',), - ), - ] diff --git a/bookwyrm/migrations/0049_passwordreset.py b/bookwyrm/migrations/0049_passwordreset.py deleted file mode 100644 index a9e784ad2..000000000 --- a/bookwyrm/migrations/0049_passwordreset.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 3.0.7 on 2020-10-02 19:43 - -import bookwyrm.models.site -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0048_generatednote'), - ] - - operations = [ - migrations.CreateModel( - name='PasswordReset', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('code', models.CharField(default=bookwyrm.models.site.new_access_code, max_length=32)), - ('expiry', models.DateTimeField(default=bookwyrm.models.site.get_passowrd_reset_expiry)), - ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/bookwyrm/migrations/0050_auto_20201002_2156.py b/bookwyrm/migrations/0050_auto_20201002_2156.py deleted file mode 100644 index 96cf7ff65..000000000 --- a/bookwyrm/migrations/0050_auto_20201002_2156.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.7 on 2020-10-02 21:56 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0049_passwordreset'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='email', - field=models.EmailField(max_length=254, unique=True), - ), - ] diff --git a/bookwyrm/migrations/0051_auto_20201005_2142.py b/bookwyrm/migrations/0051_auto_20201005_2142.py deleted file mode 100644 index f25f77d74..000000000 --- a/bookwyrm/migrations/0051_auto_20201005_2142.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.7 on 2020-10-05 21:42 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0050_auto_20201002_2156'), - ] - - operations = [ - migrations.AlterField( - model_name='sitesettings', - name='name', - field=models.CharField(default='BookWyrm', max_length=100), - ), - ] diff --git a/bookwyrm/migrations/0052_auto_20201005_2145.py b/bookwyrm/migrations/0052_auto_20201005_2145.py deleted file mode 100644 index 430c8358d..000000000 --- a/bookwyrm/migrations/0052_auto_20201005_2145.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.7 on 2020-10-05 21:45 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bookwyrm', '0051_auto_20201005_2142'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='email', - field=models.EmailField(blank=True, max_length=254, verbose_name='email address'), - ), - ] diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py index 47ae177bb..b9a2814e6 100644 --- a/bookwyrm/models/__init__.py +++ b/bookwyrm/models/__init__.py @@ -2,19 +2,31 @@ import inspect import sys -from .book import Book, Work, Edition, Author +from .book import Book, Work, Edition +from .author import Author from .connector import Connector -from .relationship import UserFollows, UserFollowRequest, UserBlocks + from .shelf import Shelf, ShelfBook -from .status import Status, GeneratedStatus, Review, Comment, Quotation + +from .status import Status, GeneratedNote, Review, Comment, Quotation from .status import Favorite, Boost, Notification, ReadThrough -from .tag import Tag -from .user import User +from .attachment import Image + +from .tag import Tag, UserTag + +from .user import User, KeyPair +from .relationship import UserFollows, UserFollowRequest, UserBlocks from .federated_server import FederatedServer from .import_job import ImportJob, ImportItem + from .site import SiteSettings, SiteInvite, PasswordReset cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass) -activity_models = {c[0]: c[1].activity_serializer for c in cls_members \ - if hasattr(c[1], 'activity_serializer')} +activity_models = {c[1].activity_serializer.__name__: c[1] \ + for c in cls_members if hasattr(c[1], 'activity_serializer')} + +def to_activity(activity_json): + ''' link up models and activities ''' + activity_type = activity_json.get('type') + return activity_models[activity_type].to_activity(activity_json) diff --git a/bookwyrm/models/attachment.py b/bookwyrm/models/attachment.py new file mode 100644 index 000000000..b3337e151 --- /dev/null +++ b/bookwyrm/models/attachment.py @@ -0,0 +1,30 @@ +''' media that is posted in the app ''' +from django.db import models + +from bookwyrm import activitypub +from .base_model import ActivitypubMixin +from .base_model import BookWyrmModel +from . import fields + + +class Attachment(ActivitypubMixin, BookWyrmModel): + ''' an image (or, in the future, video etc) associated with a status ''' + status = models.ForeignKey( + 'Status', + on_delete=models.CASCADE, + related_name='attachments', + null=True + ) + reverse_unfurl = True + class Meta: + ''' one day we'll have other types of attachments besides images ''' + abstract = True + + +class Image(Attachment): + ''' an image attachment ''' + image = fields.ImageField( + upload_to='status/', null=True, blank=True, activitypub_field='url') + caption = fields.TextField(null=True, blank=True, activitypub_field='name') + + activity_serializer = activitypub.Image diff --git a/bookwyrm/models/author.py b/bookwyrm/models/author.py new file mode 100644 index 000000000..79973a37a --- /dev/null +++ b/bookwyrm/models/author.py @@ -0,0 +1,43 @@ +''' database schema for info about authors ''' +from django.db import models +from django.utils import timezone + +from bookwyrm import activitypub +from bookwyrm.settings import DOMAIN + +from .base_model import ActivitypubMixin, BookWyrmModel +from . import fields + + +class Author(ActivitypubMixin, BookWyrmModel): + ''' basic biographic info ''' + origin_id = models.CharField(max_length=255, null=True) + openlibrary_key = fields.CharField( + max_length=255, blank=True, null=True, deduplication_field=True) + sync = models.BooleanField(default=True) + last_sync_date = models.DateTimeField(default=timezone.now) + wikipedia_link = fields.CharField(max_length=255, blank=True, null=True, deduplication_field=True) + # idk probably other keys would be useful here? + born = fields.DateTimeField(blank=True, null=True) + died = fields.DateTimeField(blank=True, null=True) + name = fields.CharField(max_length=255) + aliases = fields.ArrayField( + models.CharField(max_length=255), blank=True, default=list + ) + bio = fields.TextField(null=True, blank=True) + + def save(self, *args, **kwargs): + ''' can't be abstract for query reasons, but you shouldn't USE it ''' + if self.id and not self.remote_id: + self.remote_id = self.get_remote_id() + + if not self.id: + self.origin_id = self.remote_id + self.remote_id = None + return super().save(*args, **kwargs) + + def get_remote_id(self): + ''' editions and works both use "book" instead of model_name ''' + return 'https://%s/author/%s' % (DOMAIN, self.id) + + activity_serializer = activitypub.Author diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py index 978c3d1e3..f44797abc 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -1,24 +1,34 @@ ''' base model with default fields ''' from base64 import b64encode -from dataclasses import dataclass -from typing import Callable +from functools import reduce +import operator from uuid import uuid4 -from urllib.parse import urlencode from Crypto.PublicKey import RSA from Crypto.Signature import pkcs1_15 from Crypto.Hash import SHA256 +from django.core.paginator import Paginator from django.db import models +from django.db.models import Q from django.dispatch import receiver from bookwyrm import activitypub -from bookwyrm.settings import DOMAIN +from bookwyrm.settings import DOMAIN, PAGE_LENGTH +from .fields import RemoteIdField + + +PrivacyLevels = models.TextChoices('Privacy', [ + 'public', + 'unlisted', + 'followers', + 'direct' +]) class BookWyrmModel(models.Model): ''' shared fields ''' created_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True) - remote_id = models.CharField(max_length=255, null=True) + remote_id = RemoteIdField(null=True, activitypub_field='id') def get_remote_id(self): ''' generate a url that resolves to the local object ''' @@ -43,40 +53,99 @@ def execute_after_save(sender, instance, created, *args, **kwargs): instance.save() +def unfurl_related_field(related_field): + ''' load reverse lookups (like public key owner or Status attachment ''' + if hasattr(related_field, 'all'): + return [unfurl_related_field(i) for i in related_field.all()] + if related_field.reverse_unfurl: + return related_field.field_to_activity() + return related_field.remote_id + + class ActivitypubMixin: ''' add this mixin for models that are AP serializable ''' activity_serializer = lambda: {} + reverse_unfurl = False - def to_activity(self, pure=False): - ''' convert from a model to an activity ''' - if pure: - mappings = self.pure_activity_mappings - else: - mappings = self.activity_mappings + @classmethod + def find_existing_by_remote_id(cls, remote_id): + ''' look up a remote id in the db ''' + return cls.find_existing({'id': remote_id}) - fields = {} - for mapping in mappings: - if not hasattr(self, mapping.model_key) or not mapping.activity_key: + @classmethod + def find_existing(cls, data): + ''' compare data to fields that can be used for deduplation. + This always includes remote_id, but can also be unique identifiers + like an isbn for an edition ''' + filters = [] + for field in cls._meta.get_fields(): + if not hasattr(field, 'deduplication_field') or \ + not field.deduplication_field: continue - value = getattr(self, mapping.model_key) - if hasattr(value, 'remote_id'): - value = value.remote_id - fields[mapping.activity_key] = mapping.activity_formatter(value) - if pure: - return self.pure_activity_serializer( - **fields - ).serialize() - return self.activity_serializer( - **fields - ).serialize() + value = data.get(field.activitypub_field) + if not value: + continue + filters.append({field.name: value}) + + if hasattr(cls, 'origin_id') and 'id' in data: + # kinda janky, but this handles special case for books + filters.append({'origin_id': data['id']}) + + if not filters: + # if there are no deduplication fields, it will match the first + # item no matter what. this shouldn't happen but just in case. + return None + + objects = cls.objects + if hasattr(objects, 'select_subclasses'): + objects = objects.select_subclasses() + + # an OR operation on all the match fields + match = objects.filter( + reduce( + operator.or_, (Q(**f) for f in filters) + ) + ) + # there OUGHT to be only one match + return match.first() - def to_create_activity(self, user, pure=False): + def to_activity(self): + ''' convert from a model to an activity ''' + activity = {} + for field in self._meta.get_fields(): + if not hasattr(field, 'field_to_activity'): + continue + value = field.field_to_activity(getattr(self, field.name)) + if value is None: + continue + + key = field.get_activitypub_field() + if key in activity and isinstance(activity[key], list): + # handles tags on status, which accumulate across fields + activity[key] += value + else: + activity[key] = value + + if hasattr(self, 'serialize_reverse_fields'): + # for example, editions of a work + for model_field_name, activity_field_name in \ + self.serialize_reverse_fields: + related_field = getattr(self, model_field_name) + activity[activity_field_name] = \ + unfurl_related_field(related_field) + + if not activity.get('id'): + activity['id'] = self.get_remote_id() + return self.activity_serializer(**activity).serialize() + + + def to_create_activity(self, user): ''' returns the object wrapped in a Create activity ''' - activity_object = self.to_activity(pure=pure) + activity_object = self.to_activity() - signer = pkcs1_15.new(RSA.import_key(user.private_key)) + signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key)) content = activity_object['content'] signed_message = signer.sign(SHA256.new(content.encode('utf8'))) create_id = self.remote_id + '/activity' @@ -90,16 +159,27 @@ class ActivitypubMixin: return activitypub.Create( id=create_id, actor=user.remote_id, - to=['%s/followers' % user.remote_id], - cc=['https://www.w3.org/ns/activitystreams#Public'], + to=activity_object['to'], + cc=activity_object['cc'], object=activity_object, signature=signature, ).serialize() + def to_delete_activity(self, user): + ''' notice of deletion ''' + return activitypub.Delete( + id=self.remote_id + '/activity', + actor=user.remote_id, + to=['%s/followers' % user.remote_id], + cc=['https://www.w3.org/ns/activitystreams#Public'], + object=self.to_activity(), + ).serialize() + + def to_update_activity(self, user): ''' wrapper for Updates to an activity ''' - activity_id = '%s#update/%s' % (user.remote_id, uuid4()) + activity_id = '%s#update/%s' % (self.remote_id, uuid4()) return activitypub.Update( id=activity_id, actor=user.remote_id, @@ -111,10 +191,10 @@ class ActivitypubMixin: def to_undo_activity(self, user): ''' undo an action ''' return activitypub.Undo( - id='%s#undo' % user.remote_id, + id='%s#undo' % self.remote_id, actor=user.remote_id, object=self.to_activity() - ) + ).serialize() class OrderedCollectionPageMixin(ActivitypubMixin): @@ -125,77 +205,53 @@ class OrderedCollectionPageMixin(ActivitypubMixin): ''' this can be overriden if there's a special remote id, ie outbox ''' return self.remote_id - def page(self, min_id=None, max_id=None): - ''' helper function to create the pagination url ''' - params = {'page': 'true'} - if min_id: - params['min_id'] = min_id - if max_id: - params['max_id'] = max_id - return '?%s' % urlencode(params) - - def next_page(self, items): - ''' use the max id of the last item ''' - if not items.count(): - return '' - return self.page(max_id=items[items.count() - 1].id) - - def prev_page(self, items): - ''' use the min id of the first item ''' - if not items.count(): - return '' - return self.page(min_id=items[0].id) - - def to_ordered_collection_page(self, queryset, remote_id, \ - id_only=False, min_id=None, max_id=None): - ''' serialize and pagiante a queryset ''' - # TODO: weird place to define this - limit = 20 - # filters for use in the django queryset min/max - filters = {} - if min_id is not None: - filters['id__gt'] = min_id - if max_id is not None: - filters['id__lte'] = max_id - page_id = self.page(min_id=min_id, max_id=max_id) - - items = queryset.filter( - **filters - ).all()[:limit] - - if id_only: - page = [s.remote_id for s in items] - else: - page = [s.to_activity() for s in items] - return activitypub.OrderedCollectionPage( - id='%s%s' % (remote_id, page_id), - partOf=remote_id, - orderedItems=page, - next='%s%s' % (remote_id, self.next_page(items)), - prev='%s%s' % (remote_id, self.prev_page(items)) - ).serialize() def to_ordered_collection(self, queryset, \ remote_id=None, page=False, **kwargs): ''' an ordered collection of whatevers ''' remote_id = remote_id or self.remote_id if page: - return self.to_ordered_collection_page( + return to_ordered_collection_page( queryset, remote_id, **kwargs) - name = '' - if hasattr(self, 'name'): - name = self.name + name = self.name if hasattr(self, 'name') else None + owner = self.user.remote_id if hasattr(self, 'user') else '' - size = queryset.count() + paginated = Paginator(queryset, PAGE_LENGTH) return activitypub.OrderedCollection( id=remote_id, - totalItems=size, + totalItems=paginated.count, name=name, - first='%s%s' % (remote_id, self.page()), - last='%s%s' % (remote_id, self.page(min_id=0)) + owner=owner, + first='%s?page=1' % remote_id, + last='%s?page=%d' % (remote_id, paginated.num_pages) ).serialize() +def to_ordered_collection_page(queryset, remote_id, id_only=False, page=1): + ''' serialize and pagiante a queryset ''' + paginated = Paginator(queryset, PAGE_LENGTH) + + activity_page = paginated.page(page) + if id_only: + items = [s.remote_id for s in activity_page.object_list] + else: + items = [s.to_activity() for s in activity_page.object_list] + + prev_page = next_page = None + if activity_page.has_next(): + next_page = '%s?page=%d' % (remote_id, activity_page.next_page_number()) + if activity_page.has_previous(): + prev_page = '%s?page=%d' % \ + (remote_id, activity_page.previous_page_number()) + return activitypub.OrderedCollectionPage( + id='%s?page=%s' % (remote_id, page), + partOf=remote_id, + orderedItems=items, + next=next_page, + prev=prev_page + ).serialize() + + class OrderedCollectionMixin(OrderedCollectionPageMixin): ''' extends activitypub models to work as ordered collections ''' @property @@ -208,12 +264,3 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin): def to_activity(self, **kwargs): ''' an ordered collection of the specified model queryset ''' return self.to_ordered_collection(self.collection_queryset, **kwargs) - - -@dataclass(frozen=True) -class ActivityMapping: - ''' translate between an activitypub json field and a model field ''' - activity_key: str - model_key: str - activity_formatter: Callable = lambda x: x - model_formatter: Callable = lambda x: x diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index 3bec4662d..bcd4bc046 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -1,22 +1,27 @@ ''' database schema for books and shelves ''' +import re + from django.db import models from django.utils import timezone -from django.utils.http import http_date from model_utils.managers import InheritanceManager from bookwyrm import activitypub from bookwyrm.settings import DOMAIN -from bookwyrm.utils.fields import ArrayField - -from .base_model import ActivityMapping, ActivitypubMixin, BookWyrmModel +from .base_model import BookWyrmModel +from .base_model import ActivitypubMixin, OrderedCollectionPageMixin +from . import fields class Book(ActivitypubMixin, BookWyrmModel): ''' a generic book, which can mean either an edition or a work ''' + origin_id = models.CharField(max_length=255, null=True, blank=True) # these identifiers apply to both works and editions - openlibrary_key = models.CharField(max_length=255, blank=True, null=True) - librarything_key = models.CharField(max_length=255, blank=True, null=True) - goodreads_key = models.CharField(max_length=255, blank=True, null=True) + openlibrary_key = fields.CharField( + max_length=255, blank=True, null=True, deduplication_field=True) + librarything_key = fields.CharField( + max_length=255, blank=True, null=True, deduplication_field=True) + goodreads_key = fields.CharField( + max_length=255, blank=True, null=True, deduplication_field=True) # info about where the data comes from and where/if to sync sync = models.BooleanField(default=True) @@ -28,97 +33,48 @@ class Book(ActivitypubMixin, BookWyrmModel): # TODO: edit history # book/work metadata - title = models.CharField(max_length=255) - sort_title = models.CharField(max_length=255, blank=True, null=True) - subtitle = models.CharField(max_length=255, blank=True, null=True) - description = models.TextField(blank=True, null=True) - languages = ArrayField( + title = fields.CharField(max_length=255) + sort_title = fields.CharField(max_length=255, blank=True, null=True) + subtitle = fields.CharField(max_length=255, blank=True, null=True) + description = fields.TextField(blank=True, null=True) + languages = fields.ArrayField( models.CharField(max_length=255), blank=True, default=list ) - series = models.CharField(max_length=255, blank=True, null=True) - series_number = models.CharField(max_length=255, blank=True, null=True) - subjects = ArrayField( - models.CharField(max_length=255), blank=True, default=list + series = fields.CharField(max_length=255, blank=True, null=True) + series_number = fields.CharField(max_length=255, blank=True, null=True) + subjects = fields.ArrayField( + models.CharField(max_length=255), blank=True, null=True, default=list ) - subject_places = ArrayField( - models.CharField(max_length=255), blank=True, default=list + subject_places = fields.ArrayField( + models.CharField(max_length=255), blank=True, null=True, default=list ) # TODO: include an annotation about the type of authorship (ie, translator) - authors = models.ManyToManyField('Author') + authors = fields.ManyToManyField('Author') # preformatted authorship string for search and easier display author_text = models.CharField(max_length=255, blank=True, null=True) - cover = models.ImageField(upload_to='covers/', blank=True, null=True) - first_published_date = models.DateTimeField(blank=True, null=True) - published_date = models.DateTimeField(blank=True, null=True) + cover = fields.ImageField(upload_to='covers/', blank=True, null=True) + first_published_date = fields.DateTimeField(blank=True, null=True) + published_date = fields.DateTimeField(blank=True, null=True) + objects = InheritanceManager() - @property - def ap_authors(self): - ''' the activitypub serialization should be a list of author ids ''' - return [a.remote_id for a in self.authors.all()] - - activity_mappings = [ - ActivityMapping('id', 'remote_id'), - - ActivityMapping('authors', 'ap_authors'), - ActivityMapping( - 'first_published_date', - 'first_published_date', - activity_formatter=lambda d: http_date(d.timestamp()) if d else None - ), - ActivityMapping( - 'published_date', - 'published_date', - activity_formatter=lambda d: http_date(d.timestamp()) if d else None - ), - - ActivityMapping('title', 'title'), - ActivityMapping('sort_title', 'sort_title'), - ActivityMapping('subtitle', 'subtitle'), - ActivityMapping('description', 'description'), - ActivityMapping('languages', 'languages'), - ActivityMapping('series', 'series'), - ActivityMapping('series_number', 'series_number'), - ActivityMapping('subjects', 'subjects'), - ActivityMapping('subject_places', 'subject_places'), - - ActivityMapping('openlibrary_key', 'openlibrary_key'), - ActivityMapping('librarything_key', 'librarything_key'), - ActivityMapping('goodreads_key', 'goodreads_key'), - - ActivityMapping('work', 'parent_work'), - ActivityMapping('isbn_10', 'isbn_10'), - ActivityMapping('isbn_13', 'isbn_13'), - ActivityMapping('oclc_number', 'oclc_number'), - ActivityMapping('asin', 'asin'), - ActivityMapping('pages', 'pages'), - ActivityMapping('physical_format', 'physical_format'), - ActivityMapping('publishers', 'publishers'), - - ActivityMapping('lccn', 'lccn'), - ActivityMapping('editions', 'editions_path'), - ] - def save(self, *args, **kwargs): ''' can't be abstract for query reasons, but you shouldn't USE it ''' if not isinstance(self, Edition) and not isinstance(self, Work): raise ValueError('Books should be added as Editions or Works') - super().save(*args, **kwargs) + if self.id and not self.remote_id: + self.remote_id = self.get_remote_id() + + if not self.id: + self.origin_id = self.remote_id + self.remote_id = None + return super().save(*args, **kwargs) def get_remote_id(self): ''' editions and works both use "book" instead of model_name ''' return 'https://%s/book/%d' % (DOMAIN, self.id) - - @property - def local_id(self): - ''' when a book is ingested from an outside source, it becomes local to - an instance, so it needs a local url for federation. but it still needs - the remote_id for easier deduplication and, if appropriate, to sync with - the remote canonical copy ''' - return 'https://%s/book/%d' % (DOMAIN, self.id) - def __repr__(self): return "<{} key={!r} title={!r}>".format( self.__class__, @@ -127,41 +83,41 @@ class Book(ActivitypubMixin, BookWyrmModel): ) -class Work(Book): +class Work(OrderedCollectionPageMixin, Book): ''' a work (an abstract concept of a book that manifests in an edition) ''' # library of congress catalog control number - lccn = models.CharField(max_length=255, blank=True, null=True) + lccn = fields.CharField( + max_length=255, blank=True, null=True, deduplication_field=True) + # this has to be nullable but should never be null + default_edition = fields.ForeignKey( + 'Edition', + on_delete=models.PROTECT, + null=True + ) - @property - def editions_path(self): - ''' it'd be nice to serialize the edition instead but, recursion ''' - return self.remote_id + '/editions' - - - @property - def default_edition(self): - ''' best-guess attempt at picking the default edition for this work ''' - ed = Edition.objects.filter(parent_work=self, default=True).first() - if not ed: - ed = Edition.objects.filter(parent_work=self).first() - return ed + def get_default_edition(self): + ''' in case the default edition is not set ''' + return self.default_edition or self.editions.first() activity_serializer = activitypub.Work + serialize_reverse_fields = [('editions', 'editions')] + deserialize_reverse_fields = [('editions', 'editions')] class Edition(Book): ''' an edition of a book ''' - # default -> this is what gets displayed for a work - default = models.BooleanField(default=False) - # these identifiers only apply to editions, not works - isbn_10 = models.CharField(max_length=255, blank=True, null=True) - isbn_13 = models.CharField(max_length=255, blank=True, null=True) - oclc_number = models.CharField(max_length=255, blank=True, null=True) - asin = models.CharField(max_length=255, blank=True, null=True) - pages = models.IntegerField(blank=True, null=True) - physical_format = models.CharField(max_length=255, blank=True, null=True) - publishers = ArrayField( + isbn_10 = fields.CharField( + max_length=255, blank=True, null=True, deduplication_field=True) + isbn_13 = fields.CharField( + max_length=255, blank=True, null=True, deduplication_field=True) + oclc_number = fields.CharField( + max_length=255, blank=True, null=True, deduplication_field=True) + asin = fields.CharField( + max_length=255, blank=True, null=True, deduplication_field=True) + pages = fields.IntegerField(blank=True, null=True) + physical_format = fields.CharField(max_length=255, blank=True, null=True) + publishers = fields.ArrayField( models.CharField(max_length=255), blank=True, default=list ) shelves = models.ManyToManyField( @@ -170,55 +126,61 @@ class Edition(Book): through='ShelfBook', through_fields=('book', 'shelf') ) - parent_work = models.ForeignKey('Work', on_delete=models.PROTECT, null=True) + parent_work = fields.ForeignKey( + 'Work', on_delete=models.PROTECT, null=True, + related_name='editions', activitypub_field='work') activity_serializer = activitypub.Edition + name_field = 'title' + + def save(self, *args, **kwargs): + ''' calculate isbn 10/13 ''' + if self.isbn_13 and self.isbn_13[:3] == '978' and not self.isbn_10: + self.isbn_10 = isbn_13_to_10(self.isbn_13) + if self.isbn_10 and not self.isbn_13: + self.isbn_13 = isbn_10_to_13(self.isbn_10) + + return super().save(*args, **kwargs) -class Author(ActivitypubMixin, BookWyrmModel): - ''' copy of an author from OL ''' - openlibrary_key = models.CharField(max_length=255, blank=True, null=True) - sync = models.BooleanField(default=True) - last_sync_date = models.DateTimeField(default=timezone.now) - wikipedia_link = models.CharField(max_length=255, blank=True, null=True) - # idk probably other keys would be useful here? - born = models.DateTimeField(blank=True, null=True) - died = models.DateTimeField(blank=True, null=True) - name = models.CharField(max_length=255) - last_name = models.CharField(max_length=255, blank=True, null=True) - first_name = models.CharField(max_length=255, blank=True, null=True) - aliases = ArrayField( - models.CharField(max_length=255), blank=True, default=list - ) - bio = models.TextField(null=True, blank=True) +def isbn_10_to_13(isbn_10): + ''' convert an isbn 10 into an isbn 13 ''' + isbn_10 = re.sub(r'[^0-9X]', '', isbn_10) + # drop the last character of the isbn 10 number (the original checkdigit) + converted = isbn_10[:9] + # add "978" to the front + converted = '978' + converted + # add a check digit to the end + # multiply the odd digits by 1 and the even digits by 3 and sum them + try: + checksum = sum(int(i) for i in converted[::2]) + \ + sum(int(i) * 3 for i in converted[1::2]) + except ValueError: + return None + # add the checksum mod 10 to the end + checkdigit = checksum % 10 + if checkdigit != 0: + checkdigit = 10 - checkdigit + return converted + str(checkdigit) - @property - def local_id(self): - ''' when a book is ingested from an outside source, it becomes local to - an instance, so it needs a local url for federation. but it still needs - the remote_id for easier deduplication and, if appropriate, to sync with - the remote canonical copy (ditto here for author)''' - return 'https://%s/book/%d' % (DOMAIN, self.id) - @property - def display_name(self): - ''' Helper to return a displayable name''' - if self.name: - return self.name - # don't want to return a spurious space if all of these are None - if self.first_name and self.last_name: - return self.first_name + ' ' + self.last_name - return self.last_name or self.first_name +def isbn_13_to_10(isbn_13): + ''' convert isbn 13 to 10, if possible ''' + if isbn_13[:3] != '978': + return None - activity_mappings = [ - ActivityMapping('id', 'remote_id'), - ActivityMapping('url', 'remote_id'), - ActivityMapping('name', 'display_name'), - ActivityMapping('born', 'born'), - ActivityMapping('died', 'died'), - ActivityMapping('aliases', 'aliases'), - ActivityMapping('bio', 'bio'), - ActivityMapping('openlibrary_key', 'openlibrary_key'), - ActivityMapping('wikipedia_link', 'wikipedia_link'), - ] - activity_serializer = activitypub.Author + isbn_13 = re.sub(r'[^0-9X]', '', isbn_13) + + # remove '978' and old checkdigit + converted = isbn_13[3:-1] + # calculate checkdigit + # multiple each digit by 10,9,8.. successively and sum them + try: + checksum = sum(int(d) * (10 - idx) for (idx, d) in enumerate(converted)) + except ValueError: + return None + checkdigit = checksum % 11 + checkdigit = 11 - checkdigit + if checkdigit == 10: + checkdigit = 'X' + return converted + str(checkdigit) diff --git a/bookwyrm/models/connector.py b/bookwyrm/models/connector.py index 2a9d496b0..6f64cdf3e 100644 --- a/bookwyrm/models/connector.py +++ b/bookwyrm/models/connector.py @@ -10,25 +10,25 @@ class Connector(BookWyrmModel): ''' book data source connectors ''' identifier = models.CharField(max_length=255, unique=True) priority = models.IntegerField(default=2) - name = models.CharField(max_length=255, null=True) + name = models.CharField(max_length=255, null=True, blank=True) local = models.BooleanField(default=False) connector_file = models.CharField( max_length=255, choices=ConnectorFiles.choices ) - api_key = models.CharField(max_length=255, null=True) + api_key = models.CharField(max_length=255, null=True, blank=True) base_url = models.CharField(max_length=255) books_url = models.CharField(max_length=255) covers_url = models.CharField(max_length=255) - search_url = models.CharField(max_length=255, null=True) + search_url = models.CharField(max_length=255, null=True, blank=True) - politeness_delay = models.IntegerField(null=True) #seconds - max_query_count = models.IntegerField(null=True) + politeness_delay = models.IntegerField(null=True, blank=True) #seconds + max_query_count = models.IntegerField(null=True, blank=True) # how many queries executed in a unit of time, like a day query_count = models.IntegerField(default=0) # when to reset the query count back to 0 (ie, after 1 day) - query_count_expiry = models.DateTimeField(auto_now_add=True) + query_count_expiry = models.DateTimeField(auto_now_add=True, blank=True) class Meta: ''' check that there's code to actually use this connector ''' @@ -38,3 +38,9 @@ class Connector(BookWyrmModel): name='connector_file_valid' ) ] + + def __str__(self): + return "{} ({})".format( + self.identifier, + self.id, + ) diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py new file mode 100644 index 000000000..e6878fb90 --- /dev/null +++ b/bookwyrm/models/fields.py @@ -0,0 +1,273 @@ +''' activitypub-aware django model fields ''' +import re +from uuid import uuid4 + +import dateutil.parser +from dateutil.parser import ParserError +from django.contrib.auth.models import AbstractUser +from django.contrib.postgres.fields import ArrayField as DjangoArrayField +from django.core.exceptions import ValidationError +from django.core.files.base import ContentFile +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from bookwyrm import activitypub +from bookwyrm.settings import DOMAIN +from bookwyrm.connectors import get_image + + +def validate_remote_id(value): + ''' make sure the remote_id looks like a url ''' + if not value or not re.match(r'^http.?:\/\/[^\s]+$', value): + raise ValidationError( + _('%(value)s is not a valid remote_id'), + params={'value': value}, + ) + + +class ActivitypubFieldMixin: + ''' make a database field serializable ''' + def __init__(self, *args, \ + activitypub_field=None, activitypub_wrapper=None, + deduplication_field=False, **kwargs): + self.deduplication_field = deduplication_field + if activitypub_wrapper: + self.activitypub_wrapper = activitypub_field + self.activitypub_field = activitypub_wrapper + else: + self.activitypub_field = activitypub_field + super().__init__(*args, **kwargs) + + def field_to_activity(self, value): + ''' formatter to convert a model value into activitypub ''' + if hasattr(self, 'activitypub_wrapper'): + return {self.activitypub_wrapper: value} + return value + + def field_from_activity(self, value): + ''' formatter to convert activitypub into a model value ''' + if hasattr(self, 'activitypub_wrapper'): + value = value.get(self.activitypub_wrapper) + return value + + def get_activitypub_field(self): + ''' model_field_name to activitypubFieldName ''' + if self.activitypub_field: + return self.activitypub_field + name = self.name.split('.')[-1] + components = name.split('_') + return components[0] + ''.join(x.title() for x in components[1:]) + + +class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin): + ''' default (de)serialization for foreign key and one to one ''' + def field_from_activity(self, value): + if not value: + return None + + related_model = self.related_model + if isinstance(value, dict) and value.get('id'): + # this is an activitypub object, which we can deserialize + activity_serializer = related_model.activity_serializer + return activity_serializer(**value).to_model(related_model) + try: + # make sure the value looks like a remote id + validate_remote_id(value) + except ValidationError: + # we don't know what this is, ignore it + return None + # gets or creates the model field from the remote id + return activitypub.resolve_remote_id(related_model, value) + + +class RemoteIdField(ActivitypubFieldMixin, models.CharField): + ''' a url that serves as a unique identifier ''' + def __init__(self, *args, max_length=255, validators=None, **kwargs): + validators = validators or [validate_remote_id] + super().__init__( + *args, max_length=max_length, validators=validators, + **kwargs + ) + # for this field, the default is true. false everywhere else. + self.deduplication_field = kwargs.get('deduplication_field', True) + + +class UsernameField(ActivitypubFieldMixin, models.CharField): + ''' activitypub-aware username field ''' + def __init__(self, activitypub_field='preferredUsername'): + self.activitypub_field = activitypub_field + # I don't totally know why pylint is mad at this, but it makes it work + super( #pylint: disable=bad-super-call + ActivitypubFieldMixin, self + ).__init__( + _('username'), + max_length=150, + unique=True, + validators=[AbstractUser.username_validator], + error_messages={ + 'unique': _('A user with that username already exists.'), + }, + ) + + def deconstruct(self): + ''' implementation of models.Field deconstruct ''' + name, path, args, kwargs = super().deconstruct() + del kwargs['verbose_name'] + del kwargs['max_length'] + del kwargs['unique'] + del kwargs['validators'] + del kwargs['error_messages'] + return name, path, args, kwargs + + def field_to_activity(self, value): + return value.split('@')[0] + + +class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey): + ''' activitypub-aware foreign key field ''' + def field_to_activity(self, value): + if not value: + return None + return value.remote_id + + +class OneToOneField(ActivitypubRelatedFieldMixin, models.OneToOneField): + ''' activitypub-aware foreign key field ''' + def field_to_activity(self, value): + if not value: + return None + return value.to_activity() + + +class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField): + ''' activitypub-aware many to many field ''' + def __init__(self, *args, link_only=False, **kwargs): + self.link_only = link_only + super().__init__(*args, **kwargs) + + def field_to_activity(self, value): + if self.link_only: + return '%s/%s' % (value.instance.remote_id, self.name) + return [i.remote_id for i in value.all()] + + def field_from_activity(self, value): + items = [] + for remote_id in value: + try: + validate_remote_id(remote_id) + except ValidationError: + continue + items.append( + activitypub.resolve_remote_id(self.related_model, remote_id) + ) + return items + + +class TagField(ManyToManyField): + ''' special case of many to many that uses Tags ''' + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.activitypub_field = 'tag' + + def field_to_activity(self, value): + tags = [] + for item in value.all(): + activity_type = item.__class__.__name__ + if activity_type == 'User': + activity_type = 'Mention' + tags.append(activitypub.Link( + href=item.remote_id, + name=getattr(item, item.name_field), + type=activity_type + )) + return tags + + def field_from_activity(self, value): + if not isinstance(value, list): + return None + items = [] + for link_json in value: + link = activitypub.Link(**link_json) + tag_type = link.type if link.type != 'Mention' else 'Person' + if tag_type != self.related_model.activity_serializer.type: + # tags can contain multiple types + continue + items.append( + activitypub.resolve_remote_id(self.related_model, link.href) + ) + return items + + +def image_serializer(value): + ''' helper for serializing images ''' + if value and hasattr(value, 'url'): + url = value.url + else: + return None + url = 'https://%s%s' % (DOMAIN, url) + return activitypub.Image(url=url) + + +class ImageField(ActivitypubFieldMixin, models.ImageField): + ''' activitypub-aware image field ''' + def field_to_activity(self, value): + return image_serializer(value) + + def field_from_activity(self, value): + image_slug = value + # when it's an inline image (User avatar/icon, Book cover), it's a json + # blob, but when it's an attached image, it's just a url + if isinstance(image_slug, dict): + url = image_slug.get('url') + elif isinstance(image_slug, str): + url = image_slug + else: + return None + + try: + validate_remote_id(url) + except ValidationError: + return None + + response = get_image(url) + if not response: + return None + + image_name = str(uuid4()) + '.' + url.split('.')[-1] + image_content = ContentFile(response.content) + return [image_name, image_content] + + +class DateTimeField(ActivitypubFieldMixin, models.DateTimeField): + ''' activitypub-aware datetime field ''' + def field_to_activity(self, value): + if not value: + return None + return value.isoformat() + + def field_from_activity(self, value): + try: + date_value = dateutil.parser.parse(value) + try: + return timezone.make_aware(date_value) + except ValueError: + return date_value + except (ParserError, TypeError): + return None + +class ArrayField(ActivitypubFieldMixin, DjangoArrayField): + ''' activitypub-aware array field ''' + def field_to_activity(self, value): + return [str(i) for i in value] + +class CharField(ActivitypubFieldMixin, models.CharField): + ''' activitypub-aware char field ''' + +class TextField(ActivitypubFieldMixin, models.TextField): + ''' activitypub-aware text field ''' + +class BooleanField(ActivitypubFieldMixin, models.BooleanField): + ''' activitypub-aware boolean field ''' + +class IntegerField(ActivitypubFieldMixin, models.IntegerField): + ''' activitypub-aware boolean field ''' diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py index 9eb00e046..fe39325f0 100644 --- a/bookwyrm/models/import_job.py +++ b/bookwyrm/models/import_job.py @@ -9,6 +9,8 @@ from bookwyrm import books_manager from bookwyrm.connectors import ConnectorException from bookwyrm.models import ReadThrough, User, Book from bookwyrm.utils.fields import JSONField +from .base_model import PrivacyLevels + # Mapping goodreads -> bookwyrm shelf titles. GOODREADS_SHELVES = { @@ -40,8 +42,14 @@ class ImportJob(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) created_date = models.DateTimeField(default=timezone.now) task_id = models.CharField(max_length=100, null=True) - import_status = models.ForeignKey( - 'Status', null=True, on_delete=models.PROTECT) + include_reviews = models.BooleanField(default=True) + privacy = models.CharField( + max_length=255, + default='public', + choices=PrivacyLevels.choices + ) + retry = models.BooleanField(default=False) + class ImportItem(models.Model): ''' a single line of a csv being imported ''' @@ -64,13 +72,14 @@ class ImportItem(models.Model): def get_book_from_isbn(self): ''' search by isbn ''' - search_result = books_manager.first_search_result(self.isbn) + search_result = books_manager.first_search_result( + self.isbn, min_confidence=0.999 + ) if search_result: - try: - # don't crash the import when the connector fails - return books_manager.get_or_create_book(search_result.key) - except ConnectorException: - pass + # raises ConnectorException + return books_manager.get_or_create_book(search_result.key) + return None + def get_book_from_title_author(self): ''' search by title and author ''' @@ -78,9 +87,24 @@ class ImportItem(models.Model): self.data['Title'], self.data['Author'] ) - search_result = books_manager.first_search_result(search_term) + search_result = books_manager.first_search_result( + search_term, min_confidence=0.999 + ) if search_result: + # raises ConnectorException return books_manager.get_or_create_book(search_result.key) + return None + + + @property + def title(self): + ''' get the book title ''' + return self.data['Title'] + + @property + def author(self): + ''' get the book title ''' + return self.data['Author'] @property def isbn(self): @@ -92,6 +116,7 @@ class ImportItem(models.Model): ''' the goodreads shelf field ''' if self.data['Exclusive Shelf']: return GOODREADS_SHELVES.get(self.data['Exclusive Shelf']) + return None @property def review(self): @@ -107,13 +132,17 @@ class ImportItem(models.Model): def date_added(self): ''' when the book was added to this dataset ''' if self.data['Date Added']: - return dateutil.parser.parse(self.data['Date Added']) + return timezone.make_aware( + dateutil.parser.parse(self.data['Date Added'])) + return None @property def date_read(self): ''' the date a book was completed ''' if self.data['Date Read']: - return dateutil.parser.parse(self.data['Date Read']) + return timezone.make_aware( + dateutil.parser.parse(self.data['Date Read'])) + return None @property def reads(self): @@ -123,6 +152,7 @@ class ImportItem(models.Model): return [ReadThrough(start_date=self.date_added)] if self.date_read: return [ReadThrough( + start_date=self.date_added, finish_date=self.date_read, )] return [] diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py index e357955ec..8913b9ab4 100644 --- a/bookwyrm/models/relationship.py +++ b/bookwyrm/models/relationship.py @@ -2,23 +2,24 @@ from django.db import models from bookwyrm import activitypub -from .base_model import BookWyrmModel +from .base_model import ActivitypubMixin, BookWyrmModel +from . import fields -class UserRelationship(BookWyrmModel): +class UserRelationship(ActivitypubMixin, BookWyrmModel): ''' many-to-many through table for followers ''' - user_subject = models.ForeignKey( + user_subject = fields.ForeignKey( 'User', on_delete=models.PROTECT, - related_name='%(class)s_user_subject' + related_name='%(class)s_user_subject', + activitypub_field='actor', ) - user_object = models.ForeignKey( + user_object = fields.ForeignKey( 'User', on_delete=models.PROTECT, - related_name='%(class)s_user_object' + related_name='%(class)s_user_object', + activitypub_field='object', ) - # follow or follow_request for pending TODO: blocking? - relationship_id = models.CharField(max_length=100) class Meta: ''' relationships should be unique ''' @@ -34,25 +35,30 @@ class UserRelationship(BookWyrmModel): ) ] - def get_remote_id(self): + activity_serializer = activitypub.Follow + + def get_remote_id(self, status=None): ''' use shelf identifier in remote_id ''' + status = status or 'follows' base_path = self.user_subject.remote_id - return '%s#%s/%d' % (base_path, self.status, self.id) + return '%s#%s/%d' % (base_path, status, self.id) + def to_accept_activity(self): ''' generate an Accept for this follow request ''' return activitypub.Accept( - id='%s#accepts/follows/' % self.remote_id, - actor=self.user_subject.remote_id, - object=self.user_object.remote_id, + id=self.get_remote_id(status='accepts'), + actor=self.user_object.remote_id, + object=self.to_activity() ).serialize() + def to_reject_activity(self): ''' generate an Accept for this follow request ''' return activitypub.Reject( - id='%s#rejects/follows/' % self.remote_id, - actor=self.user_subject.remote_id, - object=self.user_object.remote_id, + id=self.get_remote_id(status='rejects'), + actor=self.user_object.remote_id, + object=self.to_activity() ).serialize() @@ -66,7 +72,7 @@ class UserFollows(UserRelationship): return cls( user_subject=follow_request.user_subject, user_object=follow_request.user_object, - relationship_id=follow_request.relationship_id, + remote_id=follow_request.remote_id, ) @@ -74,13 +80,16 @@ class UserFollowRequest(UserRelationship): ''' following a user requires manual or automatic confirmation ''' status = 'follow_request' - def to_activity(self): - ''' request activity ''' - return activitypub.Follow( - id=self.remote_id, - actor=self.user_subject.remote_id, - object=self.user_object.remote_id, - ).serialize() + def save(self, *args, **kwargs): + ''' make sure the follow relationship doesn't already exist ''' + try: + UserFollows.objects.get( + user_subject=self.user_subject, + user_object=self.user_object + ) + return None + except UserFollows.DoesNotExist: + return super().save(*args, **kwargs) class UserBlocks(UserRelationship): diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py index 53e557eea..fc63d198e 100644 --- a/bookwyrm/models/shelf.py +++ b/bookwyrm/models/shelf.py @@ -1,16 +1,25 @@ ''' puttin' books on shelves ''' +import re from django.db import models from bookwyrm import activitypub -from .base_model import BookWyrmModel, OrderedCollectionMixin +from .base_model import BookWyrmModel +from .base_model import OrderedCollectionMixin, PrivacyLevels +from . import fields class Shelf(OrderedCollectionMixin, BookWyrmModel): ''' a list of books owned by a user ''' - name = models.CharField(max_length=100) + name = fields.CharField(max_length=100) identifier = models.CharField(max_length=100) - user = models.ForeignKey('User', on_delete=models.PROTECT) + user = fields.ForeignKey( + 'User', on_delete=models.PROTECT, activitypub_field='owner') editable = models.BooleanField(default=True) + privacy = fields.CharField( + max_length=255, + default='public', + choices=PrivacyLevels.choices + ) books = models.ManyToManyField( 'Edition', symmetrical=False, @@ -18,6 +27,15 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel): through_fields=('shelf', 'book') ) + def save(self, *args, **kwargs): + ''' set the identifier ''' + saved = super().save(*args, **kwargs) + if not self.identifier: + slug = re.sub(r'[^\w]', '', self.name).lower() + self.identifier = '%s-%d' % (slug, self.id) + return super().save(*args, **kwargs) + return saved + @property def collection_queryset(self): ''' list of books for this shelf, overrides OrderedCollectionMixin ''' @@ -35,22 +53,27 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel): class ShelfBook(BookWyrmModel): ''' many to many join table for books and shelves ''' - book = models.ForeignKey('Edition', on_delete=models.PROTECT) - shelf = models.ForeignKey('Shelf', on_delete=models.PROTECT) - added_by = models.ForeignKey( + book = fields.ForeignKey( + 'Edition', on_delete=models.PROTECT, activitypub_field='object') + shelf = fields.ForeignKey( + 'Shelf', on_delete=models.PROTECT, activitypub_field='target') + added_by = fields.ForeignKey( 'User', blank=True, null=True, - on_delete=models.PROTECT + on_delete=models.PROTECT, + activitypub_field='actor' ) + activity_serializer = activitypub.AddBook + def to_add_activity(self, user): ''' AP for shelving a book''' return activitypub.Add( id='%s#add' % self.remote_id, actor=user.remote_id, object=self.book.to_activity(), - target=self.shelf.to_activity() + target=self.shelf.remote_id, ).serialize() def to_remove_activity(self, user): diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py index e4e893da0..bf046c776 100644 --- a/bookwyrm/models/site.py +++ b/bookwyrm/models/site.py @@ -29,6 +29,9 @@ class SiteSettings(models.Model): upload_to='static/images/', default='/static/images/favicon.ico' ) + support_link = models.CharField(max_length=255, null=True, blank=True) + support_title = models.CharField(max_length=100, null=True, blank=True) + admin_email = models.EmailField(max_length=255, null=True, blank=True) @classmethod def get(cls): @@ -66,7 +69,7 @@ class SiteInvite(models.Model): def get_passowrd_reset_expiry(): ''' give people a limited time to use the link ''' - now = datetime.datetime.now() + now = timezone.now() return now + datetime.timedelta(days=1) diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index 6c3369f21..55036f2c9 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -1,27 +1,34 @@ ''' models for storing different kinds of Activities ''' from django.utils import timezone -from django.utils.http import http_date from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from model_utils.managers import InheritanceManager from bookwyrm import activitypub from .base_model import ActivitypubMixin, OrderedCollectionPageMixin -from .base_model import ActivityMapping, BookWyrmModel - +from .base_model import BookWyrmModel, PrivacyLevels +from . import fields +from .fields import image_serializer class Status(OrderedCollectionPageMixin, BookWyrmModel): ''' any post, like a reply to a review, etc ''' - user = models.ForeignKey('User', on_delete=models.PROTECT) - content = models.TextField(blank=True, null=True) - mention_users = models.ManyToManyField('User', related_name='mention_user') - mention_books = models.ManyToManyField( - 'Edition', related_name='mention_book') + user = fields.ForeignKey( + 'User', on_delete=models.PROTECT, activitypub_field='attributedTo') + content = fields.TextField(blank=True, null=True) + mention_users = fields.TagField('User', related_name='mention_user') + mention_books = fields.TagField('Edition', related_name='mention_book') local = models.BooleanField(default=True) - privacy = models.CharField(max_length=255, default='public') - sensitive = models.BooleanField(default=False) + privacy = models.CharField( + max_length=255, + default='public', + choices=PrivacyLevels.choices + ) + sensitive = fields.BooleanField(default=False) # the created date can't be this, because of receiving federated posts - published_date = models.DateTimeField(default=timezone.now) + published_date = fields.DateTimeField( + default=timezone.now, activitypub_field='published') + deleted = models.BooleanField(default=False) + deleted_date = models.DateTimeField(blank=True, null=True) favorites = models.ManyToManyField( 'User', symmetrical=False, @@ -29,60 +36,17 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): through_fields=('status', 'user'), related_name='user_favorites' ) - reply_parent = models.ForeignKey( + reply_parent = fields.ForeignKey( 'self', null=True, - on_delete=models.PROTECT + on_delete=models.PROTECT, + activitypub_field='inReplyTo', ) objects = InheritanceManager() - # ---- activitypub serialization settings for this model ----- # - @property - def ap_to(self): - ''' should be related to post privacy I think ''' - return ['https://www.w3.org/ns/activitystreams#Public'] - - @property - def ap_cc(self): - ''' should be related to post privacy I think ''' - return [self.user.ap_followers] - - @property - def ap_replies(self): - ''' structured replies block ''' - return self.to_replies() - - shared_mappings = [ - ActivityMapping('id', 'remote_id'), - ActivityMapping('url', 'remote_id'), - ActivityMapping('inReplyTo', 'reply_parent'), - ActivityMapping( - 'published', - 'published_date', - activity_formatter=lambda d: http_date(d.timestamp()) - ), - ActivityMapping('attributedTo', 'user'), - ActivityMapping('to', 'ap_to'), - ActivityMapping('cc', 'ap_cc'), - ActivityMapping('replies', 'ap_replies'), - ] - - # serializing to bookwyrm expanded activitypub - activity_mappings = shared_mappings + [ - ActivityMapping('name', 'name'), - ActivityMapping('inReplyToBook', 'book'), - ActivityMapping('rating', 'rating'), - ActivityMapping('quote', 'quote'), - ActivityMapping('content', 'content'), - ] - - # for serializing to standard activitypub without extended types - pure_activity_mappings = shared_mappings + [ - ActivityMapping('name', 'ap_pure_name'), - ActivityMapping('content', 'ap_pure_content'), - ] - activity_serializer = activitypub.Note + serialize_reverse_fields = [('attachments', 'attachment')] + deserialize_reverse_fields = [('attachments', 'attachment')] #----- replies collection activitypub ----# @classmethod @@ -104,60 +68,118 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): **kwargs ) -class GeneratedStatus(Status): + def to_activity(self, pure=False): + ''' return tombstone if the status is deleted ''' + if self.deleted: + return activitypub.Tombstone( + id=self.remote_id, + url=self.remote_id, + deleted=self.deleted_date.isoformat(), + published=self.deleted_date.isoformat() + ).serialize() + activity = ActivitypubMixin.to_activity(self) + activity['replies'] = self.to_replies() + + # privacy controls + public = 'https://www.w3.org/ns/activitystreams#Public' + mentions = [u.remote_id for u in self.mention_users.all()] + # this is a link to the followers list: + followers = self.user.__class__._meta.get_field('followers')\ + .field_to_activity(self.user.followers) + if self.privacy == 'public': + activity['to'] = [public] + activity['cc'] = [followers] + mentions + elif self.privacy == 'unlisted': + activity['to'] = [followers] + activity['cc'] = [public] + mentions + elif self.privacy == 'followers': + activity['to'] = [followers] + activity['cc'] = mentions + if self.privacy == 'direct': + activity['to'] = mentions + activity['cc'] = [] + + # "pure" serialization for non-bookwyrm instances + if pure: + activity['content'] = self.pure_content + if 'name' in activity: + activity['name'] = self.pure_name + activity['type'] = self.pure_type + activity['attachment'] = [ + image_serializer(b.cover) for b in self.mention_books.all() \ + if b.cover] + if hasattr(self, 'book'): + activity['attachment'].append( + image_serializer(self.book.cover) + ) + return activity + + + def save(self, *args, **kwargs): + ''' update user active time ''' + if self.user.local: + self.user.last_active_date = timezone.now() + self.user.save() + return super().save(*args, **kwargs) + + +class GeneratedNote(Status): ''' these are app-generated messages about user activity ''' @property - def ap_pure_content(self): + def pure_content(self): ''' indicate the book in question for mastodon (or w/e) users ''' message = self.content books = ', '.join( - '"%s"' % (self.book.local_id, self.book.title) \ - for book in self.mention_books + '"%s"' % (book.remote_id, book.title) \ + for book in self.mention_books.all() ) - return '%s %s' % (message, books) + return '%s %s %s' % (self.user.display_name, message, books) activity_serializer = activitypub.GeneratedNote - pure_activity_serializer = activitypub.Note + pure_type = 'Note' class Comment(Status): ''' like a review but without a rating and transient ''' - book = models.ForeignKey('Edition', on_delete=models.PROTECT) + book = fields.ForeignKey( + 'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook') @property - def ap_pure_content(self): + def pure_content(self): ''' indicate the book in question for mastodon (or w/e) users ''' return self.content + '

(comment on "%s")' % \ - (self.book.local_id, self.book.title) + (self.book.remote_id, self.book.title) activity_serializer = activitypub.Comment - pure_activity_serializer = activitypub.Note + pure_type = 'Note' class Quotation(Status): ''' like a review but without a rating and transient ''' - quote = models.TextField() - book = models.ForeignKey('Edition', on_delete=models.PROTECT) + quote = fields.TextField() + book = fields.ForeignKey( + 'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook') @property - def ap_pure_content(self): + def pure_content(self): ''' indicate the book in question for mastodon (or w/e) users ''' - return '"%s"
-- "%s")

%s' % ( + return '"%s"
-- "%s"

%s' % ( self.quote, - self.book.local_id, + self.book.remote_id, self.book.title, self.content, ) activity_serializer = activitypub.Quotation - pure_activity_serializer = activitypub.Note + pure_type = 'Note' class Review(Status): ''' a book review ''' - name = models.CharField(max_length=255, null=True) - book = models.ForeignKey('Edition', on_delete=models.PROTECT) - rating = models.IntegerField( + name = fields.CharField(max_length=255, null=True) + book = fields.ForeignKey( + 'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook') + rating = fields.IntegerField( default=None, null=True, blank=True, @@ -165,38 +187,43 @@ class Review(Status): ) @property - def ap_pure_name(self): + def pure_name(self): ''' clarify review names for mastodon serialization ''' - return 'Review of "%s" (%d stars): %s' % ( + if self.rating: + return 'Review of "%s" (%d stars): %s' % ( + self.book.title, + self.rating, + self.name + ) + return 'Review of "%s": %s' % ( self.book.title, - self.rating, self.name ) @property - def ap_pure_content(self): + def pure_content(self): ''' indicate the book in question for mastodon (or w/e) users ''' return self.content + '

("%s")' % \ - (self.book.local_id, self.book.title) + (self.book.remote_id, self.book.title) activity_serializer = activitypub.Review - pure_activity_serializer = activitypub.Article + pure_type = 'Article' class Favorite(ActivitypubMixin, BookWyrmModel): ''' fav'ing a post ''' - user = models.ForeignKey('User', on_delete=models.PROTECT) - status = models.ForeignKey('Status', on_delete=models.PROTECT) - - # ---- activitypub serialization settings for this model ----- # - activity_mappings = [ - ActivityMapping('id', 'remote_id'), - ActivityMapping('actor', 'user'), - ActivityMapping('object', 'status'), - ] + user = fields.ForeignKey( + 'User', on_delete=models.PROTECT, activitypub_field='actor') + status = fields.ForeignKey( + 'Status', on_delete=models.PROTECT, activitypub_field='object') activity_serializer = activitypub.Like + def save(self, *args, **kwargs): + ''' update user active time ''' + self.user.last_active_date = timezone.now() + self.user.save() + super().save(*args, **kwargs) class Meta: ''' can't fav things twice ''' @@ -205,16 +232,12 @@ class Favorite(ActivitypubMixin, BookWyrmModel): class Boost(Status): ''' boost'ing a post ''' - boosted_status = models.ForeignKey( + boosted_status = fields.ForeignKey( 'Status', on_delete=models.PROTECT, - related_name="boosters") - - activity_mappings = [ - ActivityMapping('id', 'remote_id'), - ActivityMapping('actor', 'user'), - ActivityMapping('object', 'boosted_status'), - ] + related_name='boosters', + activitypub_field='object', + ) activity_serializer = activitypub.Boost @@ -237,10 +260,16 @@ class ReadThrough(BookWyrmModel): blank=True, null=True) + def save(self, *args, **kwargs): + ''' update user active time ''' + self.user.last_active_date = timezone.now() + self.user.save() + super().save(*args, **kwargs) + NotificationType = models.TextChoices( 'NotificationType', - 'FAVORITE REPLY TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT') + 'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT') class Notification(BookWyrmModel): ''' you've been tagged, liked, followed, etc ''' diff --git a/bookwyrm/models/tag.py b/bookwyrm/models/tag.py index 2c6d7a980..940b41924 100644 --- a/bookwyrm/models/tag.py +++ b/bookwyrm/models/tag.py @@ -6,13 +6,12 @@ from django.db import models from bookwyrm import activitypub from bookwyrm.settings import DOMAIN from .base_model import OrderedCollectionMixin, BookWyrmModel +from . import fields class Tag(OrderedCollectionMixin, BookWyrmModel): ''' freeform tags for books ''' - user = models.ForeignKey('User', on_delete=models.PROTECT) - book = models.ForeignKey('Edition', on_delete=models.PROTECT) - name = models.CharField(max_length=100) + name = fields.CharField(max_length=100, unique=True) identifier = models.CharField(max_length=100) @classmethod @@ -30,13 +29,33 @@ class Tag(OrderedCollectionMixin, BookWyrmModel): base_path = 'https://%s' % DOMAIN return '%s/tag/%s' % (base_path, self.identifier) + + def save(self, *args, **kwargs): + ''' create a url-safe lookup key for the tag ''' + if not self.id: + # add identifiers to new tags + self.identifier = urllib.parse.quote_plus(self.name) + super().save(*args, **kwargs) + + +class UserTag(BookWyrmModel): + ''' an instance of a tag on a book by a user ''' + user = fields.ForeignKey( + 'User', on_delete=models.PROTECT, activitypub_field='actor') + book = fields.ForeignKey( + 'Edition', on_delete=models.PROTECT, activitypub_field='object') + tag = fields.ForeignKey( + 'Tag', on_delete=models.PROTECT, activitypub_field='target') + + activity_serializer = activitypub.AddBook + def to_add_activity(self, user): ''' AP for shelving a book''' return activitypub.Add( id='%s#add' % self.remote_id, actor=user.remote_id, object=self.book.to_activity(), - target=self.to_activity(), + target=self.remote_id, ).serialize() def to_remove_activity(self, user): @@ -48,13 +67,7 @@ class Tag(OrderedCollectionMixin, BookWyrmModel): target=self.to_activity(), ).serialize() - def save(self, *args, **kwargs): - ''' create a url-safe lookup key for the tag ''' - if not self.id: - # add identifiers to new tags - self.identifier = urllib.parse.quote_plus(self.name) - super().save(*args, **kwargs) class Meta: ''' unqiueness constraint ''' - unique_together = ('user', 'book', 'name') + unique_together = ('user', 'book', 'tag') diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index 0cd8b9788..63549d360 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -6,43 +6,61 @@ from django.db import models from django.dispatch import receiver from bookwyrm import activitypub +from bookwyrm.connectors import get_data from bookwyrm.models.shelf import Shelf -from bookwyrm.models.status import Status +from bookwyrm.models.status import Status, Review from bookwyrm.settings import DOMAIN from bookwyrm.signatures import create_key_pair +from bookwyrm.tasks import app from .base_model import OrderedCollectionPageMixin -from .base_model import ActivityMapping +from .base_model import ActivitypubMixin, BookWyrmModel +from .federated_server import FederatedServer +from . import fields class User(OrderedCollectionPageMixin, AbstractUser): ''' a user who wants to read books ''' - private_key = models.TextField(blank=True, null=True) - public_key = models.TextField(blank=True, null=True) - inbox = models.CharField(max_length=255, unique=True) - shared_inbox = models.CharField(max_length=255, blank=True, null=True) + username = fields.UsernameField() + + key_pair = fields.OneToOneField( + 'KeyPair', + on_delete=models.CASCADE, + blank=True, null=True, + activitypub_field='publicKey', + related_name='owner' + ) + inbox = fields.RemoteIdField(unique=True) + shared_inbox = fields.RemoteIdField( + activitypub_field='sharedInbox', + activitypub_wrapper='endpoints', + deduplication_field=False, + null=True) federated_server = models.ForeignKey( 'FederatedServer', on_delete=models.PROTECT, null=True, + blank=True, ) - outbox = models.CharField(max_length=255, unique=True) - summary = models.TextField(blank=True, null=True) - local = models.BooleanField(default=True) - bookwyrm_user = models.BooleanField(default=True) + outbox = fields.RemoteIdField(unique=True) + summary = fields.TextField(default='') + local = models.BooleanField(default=False) + bookwyrm_user = fields.BooleanField(default=True) localname = models.CharField( max_length=255, null=True, unique=True ) # name is your display name, which you can change at will - name = models.CharField(max_length=100, blank=True, null=True) - avatar = models.ImageField(upload_to='avatars/', blank=True, null=True) - following = models.ManyToManyField( + name = fields.CharField(max_length=100, default='') + avatar = fields.ImageField( + upload_to='avatars/', blank=True, null=True, activitypub_field='icon') + followers = fields.ManyToManyField( 'self', + link_only=True, symmetrical=False, through='UserFollows', - through_fields=('user_subject', 'user_object'), - related_name='followers' + through_fields=('user_object', 'user_subject'), + related_name='following' ) follow_requests = models.ManyToManyField( 'self', @@ -65,93 +83,44 @@ class User(OrderedCollectionPageMixin, AbstractUser): through_fields=('user', 'status'), related_name='favorite_statuses' ) - remote_id = models.CharField(max_length=255, null=True, unique=True) + remote_id = fields.RemoteIdField( + null=True, unique=True, activitypub_field='id') created_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True) - manually_approves_followers = models.BooleanField(default=False) - - # ---- activitypub serialization settings for this model ----- # - @property - def ap_followers(self): - ''' generates url for activitypub followers page ''' - return '%s/followers' % self.remote_id + last_active_date = models.DateTimeField(auto_now=True) + manually_approves_followers = fields.BooleanField(default=False) @property - def ap_icon(self): - ''' send default icon if one isn't set ''' - if self.avatar: - url = self.avatar.url - # TODO not the right way to get the media type - media_type = 'image/%s' % url.split('.')[-1] - else: - url = 'https://%s/static/images/default_avi.jpg' % DOMAIN - media_type = 'image/jpeg' - return activitypub.Image(media_type, url, 'Image') + def display_name(self): + ''' show the cleanest version of the user's name possible ''' + if self.name != '': + return self.name + return self.localname or self.username - @property - def ap_public_key(self): - ''' format the public key block for activitypub ''' - return activitypub.PublicKey(**{ - 'id': '%s/#main-key' % self.remote_id, - 'owner': self.remote_id, - 'publicKeyPem': self.public_key, - }) - - activity_mappings = [ - ActivityMapping('id', 'remote_id'), - ActivityMapping( - 'preferredUsername', - 'username', - activity_formatter=lambda x: x.split('@')[0] - ), - ActivityMapping('name', 'name'), - ActivityMapping('inbox', 'inbox'), - ActivityMapping('outbox', 'outbox'), - ActivityMapping('followers', 'ap_followers'), - ActivityMapping('summary', 'summary'), - ActivityMapping( - 'publicKey', - 'public_key', - model_formatter=lambda x: x.get('publicKeyPem') - ), - ActivityMapping('publicKey', 'ap_public_key'), - ActivityMapping( - 'endpoints', - 'shared_inbox', - activity_formatter=lambda x: {'sharedInbox': x}, - model_formatter=lambda x: x.get('sharedInbox') - ), - ActivityMapping('icon', 'ap_icon'), - ActivityMapping( - 'manuallyApprovesFollowers', - 'manually_approves_followers' - ), - # this field isn't in the activity but should always be false - ActivityMapping(None, 'local', model_formatter=lambda x: False), - ] activity_serializer = activitypub.Person def to_outbox(self, **kwargs): ''' an ordered collection of statuses ''' queryset = Status.objects.filter( user=self, - ).select_subclasses() + deleted=False, + ).select_subclasses().order_by('-published_date') return self.to_ordered_collection(queryset, \ remote_id=self.outbox, **kwargs) def to_following_activity(self, **kwargs): ''' activitypub following list ''' remote_id = '%s/following' % self.remote_id - return self.to_ordered_collection(self.following, \ + return self.to_ordered_collection(self.following.all(), \ remote_id=remote_id, id_only=True, **kwargs) def to_followers_activity(self, **kwargs): ''' activitypub followers list ''' remote_id = '%s/followers' % self.remote_id - return self.to_ordered_collection(self.followers, \ + return self.to_ordered_collection(self.followers.all(), \ remote_id=remote_id, id_only=True, **kwargs) - def to_activity(self, pure=False): + def to_activity(self): ''' override default AP serializer to add context object idk if this is the best way to go about this ''' activity_object = super().to_activity() @@ -168,36 +137,73 @@ class User(OrderedCollectionPageMixin, AbstractUser): return activity_object -@receiver(models.signals.pre_save, sender=User) -def execute_before_save(sender, instance, *args, **kwargs): - ''' populate fields for new local users ''' - # this user already exists, no need to poplate fields - if instance.id: - return - if not instance.local: - # we need to generate a username that uses the domain (webfinger format) - actor_parts = urlparse(instance.remote_id) - instance.username = '%s@%s' % (instance.username, actor_parts.netloc) - return + def save(self, *args, **kwargs): + ''' populate fields for new local users ''' + # this user already exists, no need to populate fields + if self.id: + return super().save(*args, **kwargs) - # populate fields for local users - instance.remote_id = 'https://%s/user/%s' % (DOMAIN, instance.username) - instance.localname = instance.username - instance.username = '%s@%s' % (instance.username, DOMAIN) - instance.actor = instance.remote_id - instance.inbox = '%s/inbox' % instance.remote_id - instance.shared_inbox = 'https://%s/inbox' % DOMAIN - instance.outbox = '%s/outbox' % instance.remote_id - if not instance.private_key: - instance.private_key, instance.public_key = create_key_pair() + if not self.local: + # generate a username that uses the domain (webfinger format) + actor_parts = urlparse(self.remote_id) + self.username = '%s@%s' % (self.username, actor_parts.netloc) + return super().save(*args, **kwargs) + + # populate fields for local users + self.remote_id = 'https://%s/user/%s' % (DOMAIN, self.username) + self.localname = self.username + self.username = '%s@%s' % (self.username, DOMAIN) + self.actor = self.remote_id + self.inbox = '%s/inbox' % self.remote_id + self.shared_inbox = 'https://%s/inbox' % DOMAIN + self.outbox = '%s/outbox' % self.remote_id + + return super().save(*args, **kwargs) + + +class KeyPair(ActivitypubMixin, BookWyrmModel): + ''' public and private keys for a user ''' + private_key = models.TextField(blank=True, null=True) + public_key = fields.TextField( + blank=True, null=True, activitypub_field='publicKeyPem') + + activity_serializer = activitypub.PublicKey + serialize_reverse_fields = [('owner', 'owner')] + + def get_remote_id(self): + # self.owner is set by the OneToOneField on User + return '%s/#main-key' % self.owner.remote_id + + def save(self, *args, **kwargs): + ''' create a key pair ''' + if not self.public_key: + self.private_key, self.public_key = create_key_pair() + return super().save(*args, **kwargs) + + def to_activity(self): + ''' override default AP serializer to add context object + idk if this is the best way to go about this ''' + activity_object = super().to_activity() + del activity_object['@context'] + del activity_object['type'] + return activity_object @receiver(models.signals.post_save, sender=User) +#pylint: disable=unused-argument def execute_after_save(sender, instance, created, *args, **kwargs): ''' create shelves for new users ''' - if not instance.local or not created: + if not created: return + if not instance.local: + set_remote_server.delay(instance.id) + return + + instance.key_pair = KeyPair.objects.create( + remote_id='%s/#main-key' % instance.remote_id) + instance.save() + shelves = [{ 'name': 'To Read', 'identifier': 'to-read', @@ -216,3 +222,54 @@ def execute_after_save(sender, instance, created, *args, **kwargs): user=instance, editable=False ).save() + + +@app.task +def set_remote_server(user_id): + ''' figure out the user's remote server in the background ''' + user = User.objects.get(id=user_id) + actor_parts = urlparse(user.remote_id) + user.federated_server = \ + get_or_create_remote_server(actor_parts.netloc) + user.save() + if user.bookwyrm_user: + get_remote_reviews.delay(user.outbox) + + +def get_or_create_remote_server(domain): + ''' get info on a remote server ''' + try: + return FederatedServer.objects.get( + server_name=domain + ) + except FederatedServer.DoesNotExist: + pass + + data = get_data('https://%s/.well-known/nodeinfo' % domain) + + try: + nodeinfo_url = data.get('links')[0].get('href') + except (TypeError, KeyError): + return None + + data = get_data(nodeinfo_url) + + server = FederatedServer.objects.create( + server_name=domain, + application_type=data['software']['name'], + application_version=data['software']['version'], + ) + return server + + +@app.task +def get_remote_reviews(outbox): + ''' ingest reviews by a new remote bookwyrm user ''' + outbox_page = outbox + '?page=true' + data = get_data(outbox_page) + + # TODO: pagination? + for activity in data['orderedItems']: + if not activity['type'] == 'Review': + continue + activitypub.Review(**activity).to_model(Review) diff --git a/bookwyrm/outgoing.py b/bookwyrm/outgoing.py index 25a61c46e..38b482824 100644 --- a/bookwyrm/outgoing.py +++ b/bookwyrm/outgoing.py @@ -1,19 +1,20 @@ ''' handles all the activity coming out of the server ''' -from datetime import datetime +import re from django.db import IntegrityError, transaction from django.http import HttpResponseNotFound, JsonResponse from django.views.decorators.csrf import csrf_exempt -import requests +from requests import HTTPError from bookwyrm import activitypub from bookwyrm import models +from bookwyrm.connectors import get_data, ConnectorException from bookwyrm.broadcast import broadcast -from bookwyrm.status import create_review, create_status -from bookwyrm.status import create_quotation, create_comment -from bookwyrm.status import create_tag, create_notification, create_rating +from bookwyrm.status import create_notification from bookwyrm.status import create_generated_note -from bookwyrm.remote_user import get_or_create_remote_user +from bookwyrm.status import delete_status +from bookwyrm.settings import DOMAIN +from bookwyrm.utils import regex @csrf_exempt @@ -34,43 +35,48 @@ def outbox(request, username): ) -def handle_account_search(query): +def handle_remote_webfinger(query): ''' webfingerin' other servers ''' user = None - domain = query.split('@')[1] + + # usernames could be @user@domain or user@domain + if query[0] == '@': + query = query[1:] + + try: + domain = query.split('@')[1] + except IndexError: + return None + try: user = models.User.objects.get(username=query) except models.User.DoesNotExist: url = 'https://%s/.well-known/webfinger?resource=acct:%s' % \ (domain, query) try: - response = requests.get(url) - except requests.exceptions.ConnectionError: + data = get_data(url) + except (ConnectorException, HTTPError): return None - if not response.ok: - return None - data = response.json() - for link in data['links']: - if link['rel'] == 'self': + + for link in data.get('links'): + if link.get('rel') == 'self': try: - user = get_or_create_remote_user(link['href']) + user = activitypub.resolve_remote_id( + models.User, link['href'] + ) except KeyError: return None - return [user] + return user def handle_follow(user, to_follow): ''' someone local wants to follow someone ''' - try: - relationship, _ = models.UserFollowRequest.objects.get_or_create( - user_subject=user, - user_object=to_follow, - ) - except IntegrityError as err: - if err.__cause__.diag.constraint_name != 'userfollowrequest_unique': - raise + relationship, _ = models.UserFollowRequest.objects.get_or_create( + user_subject=user, + user_object=to_follow, + ) activity = relationship.to_activity() - broadcast(user, activity, direct_recipients=[to_follow]) + broadcast(user, activity, privacy='direct', direct_recipients=[to_follow]) def handle_unfollow(user, to_unfollow): @@ -80,12 +86,14 @@ def handle_unfollow(user, to_unfollow): user_object=to_unfollow ) activity = relationship.to_undo_activity(user) - broadcast(user, activity, direct_recipients=[to_unfollow]) + broadcast(user, activity, privacy='direct', direct_recipients=[to_unfollow]) to_unfollow.followers.remove(user) -def handle_accept(user, to_follow, follow_request): +def handle_accept(follow_request): ''' send an acceptance message to a follow request ''' + user = follow_request.user_subject + to_follow = follow_request.user_object with transaction.atomic(): relationship = models.UserFollows.from_request(follow_request) follow_request.delete() @@ -95,10 +103,12 @@ def handle_accept(user, to_follow, follow_request): broadcast(to_follow, activity, privacy='direct', direct_recipients=[user]) -def handle_reject(user, to_follow, relationship): +def handle_reject(follow_request): ''' a local user who managed follows rejects a follow request ''' - activity = relationship.to_reject_activity(user) - relationship.delete() + user = follow_request.user_subject + to_follow = follow_request.user_object + activity = follow_request.to_reject_activity() + follow_request.delete() broadcast(to_follow, activity, privacy='direct', direct_recipients=[user]) @@ -110,36 +120,6 @@ def handle_shelve(user, book, shelf): broadcast(user, shelve.to_add_activity(user)) - # tell the world about this cool thing that happened - message = { - 'to-read': 'wants to read', - 'reading': 'started reading', - 'read': 'finished reading' - }[shelf.identifier] - status = create_generated_note(user, message, mention_books=[book]) - status.save() - - if shelf.identifier == 'reading': - read = models.ReadThrough( - user=user, - book=book, - start_date=datetime.now()) - read.save() - elif shelf.identifier == 'read': - read = models.ReadThrough.objects.filter( - user=user, - book=book, - finish_date=None).order_by('-created_date').first() - if not read: - read = models.ReadThrough( - user=user, - book=book, - start_date=datetime.now()) - read.finish_date = datetime.now() - read.save() - - broadcast(user, status.to_create_activity(user)) - def handle_unshelve(user, book, shelf): ''' a local user is getting a book put on their shelf ''' @@ -151,95 +131,134 @@ def handle_unshelve(user, book, shelf): broadcast(user, activity) -def handle_import_books(user, items): +def handle_reading_status(user, shelf, book, privacy): + ''' post about a user reading a book ''' + # tell the world about this cool thing that happened + try: + message = { + 'to-read': 'wants to read', + 'reading': 'started reading', + 'read': 'finished reading' + }[shelf.identifier] + except KeyError: + # it's a non-standard shelf, don't worry about it + return + + status = create_generated_note( + user, + message, + mention_books=[book], + privacy=privacy + ) + status.save() + + broadcast(user, status.to_create_activity(user)) + + +def handle_imported_book(user, item, include_reviews, privacy): ''' process a goodreads csv and then post about it ''' - new_books = [] - for item in items: - if item.shelf: - desired_shelf = models.Shelf.objects.get( - identifier=item.shelf, - user=user - ) - if isinstance(item.book, models.Work): - item.book = item.book.default_edition - if not item.book: - continue - shelf_book, created = models.ShelfBook.objects.get_or_create( - book=item.book, shelf=desired_shelf, added_by=user) - if created: - new_books.append(item.book) - activity = shelf_book.to_add_activity(user) - broadcast(user, activity) + if isinstance(item.book, models.Work): + item.book = item.book.default_edition + if not item.book: + return - if item.rating or item.review: - review_title = "Review of {!r} on Goodreads".format( - item.book.title, - ) if item.review else "" - handle_review( - user, - item.book, - review_title, - item.review, - item.rating, - ) - for read in item.reads: - read.book = item.book - read.user = user - read.save() + if item.shelf: + desired_shelf = models.Shelf.objects.get( + identifier=item.shelf, + user=user + ) + # shelve the book if it hasn't been shelved already + shelf_book, created = models.ShelfBook.objects.get_or_create( + book=item.book, shelf=desired_shelf, added_by=user) + if created: + broadcast(user, shelf_book.to_add_activity(user), privacy=privacy) - if new_books: - message = 'imported {} books'.format(len(new_books)) - status = create_generated_note(user, message, mention_books=new_books) - status.save() + # only add new read-throughs if the item isn't already shelved + for read in item.reads: + read.book = item.book + read.user = user + read.save() - broadcast(user, status.to_create_activity(user)) - return status - return None + if include_reviews and (item.rating or item.review): + review_title = 'Review of {!r} on Goodreads'.format( + item.book.title, + ) if item.review else '' + + # we don't know the publication date of the review, + # but "now" is a bad guess + published_date_guess = item.date_read or item.date_added + review = models.Review.objects.create( + user=user, + book=item.book, + name=review_title, + content=item.review, + rating=item.rating, + published_date=published_date_guess, + privacy=privacy, + ) + # we don't need to send out pure activities because non-bookwyrm + # instances don't need this data + broadcast(user, review.to_create_activity(user), privacy=privacy) -def handle_rate(user, book, rating): - ''' a review that's just a rating ''' - builder = create_rating - handle_status(user, book, builder, rating) +def handle_delete_status(user, status): + ''' delete a status and broadcast deletion to other servers ''' + delete_status(status) + broadcast(user, status.to_delete_activity(user)) -def handle_review(user, book, name, content, rating): - ''' post a review ''' - # validated and saves the review in the database so it has an id - builder = create_review - handle_status(user, book, builder, name, content, rating) - - -def handle_quotation(user, book, content, quote): - ''' post a review ''' - # validated and saves the review in the database so it has an id - builder = create_quotation - handle_status(user, book, builder, content, quote) - - -def handle_comment(user, book, content): - ''' post a comment ''' - # validated and saves the review in the database so it has an id - builder = create_comment - handle_status(user, book, builder, content) - - -def handle_status(user, book_id, builder, *args): +def handle_status(user, form): ''' generic handler for statuses ''' - book = models.Edition.objects.get(id=book_id) - status = builder(user, book, *args) + status = form.save() + + # inspect the text for user tags + text = status.content + matches = re.finditer( + regex.username, + text + ) + for match in matches: + username = match.group().strip().split('@')[1:] + if len(username) == 1: + # this looks like a local user (@user), fill in the domain + username.append(DOMAIN) + username = '@'.join(username) + + mention_user = handle_remote_webfinger(username) + if not mention_user: + # we can ignore users we don't know about + continue + # add them to status mentions fk + status.mention_users.add(mention_user) + # create notification if the mentioned user is local + if mention_user.local: + create_notification( + mention_user, + 'MENTION', + related_user=user, + related_status=status + ) + status.save() + + # notify reply parent or tagged users + if status.reply_parent and status.reply_parent.user.local: + create_notification( + status.reply_parent.user, + 'REPLY', + related_user=user, + related_status=status + ) broadcast(user, status.to_create_activity(user), software='bookwyrm') # re-format the activity for non-bookwyrm servers - remote_activity = status.to_create_activity(user, pure=True) - - broadcast(user, remote_activity, software='other') + if hasattr(status, 'pure_activity_serializer'): + remote_activity = status.to_create_activity(user, pure=True) + broadcast(user, remote_activity, software='other') -def handle_tag(user, book, name): +def handle_tag(user, tag): ''' tag a book ''' - tag = create_tag(user, book, name) broadcast(user, tag.to_add_activity(user)) @@ -253,21 +272,6 @@ def handle_untag(user, book, name): broadcast(user, tag_activity) -def handle_reply(user, review, content): - ''' respond to a review or status ''' - # validated and saves the comment in the database so it has an id - reply = create_status(user, content, reply_parent=review) - if reply.reply_parent: - create_notification( - reply.reply_parent.user, - 'REPLY', - related_user=user, - related_status=reply, - ) - - broadcast(user, reply.to_create_activity(user)) - - def handle_favorite(user, status): ''' a user likes a status ''' try: @@ -282,6 +286,12 @@ def handle_favorite(user, status): fav_activity = favorite.to_activity() broadcast( user, fav_activity, privacy='direct', direct_recipients=[status.user]) + create_notification( + status.user, + 'FAVORITE', + related_user=user, + related_status=status + ) def handle_unfavorite(user, status): @@ -295,7 +305,8 @@ def handle_unfavorite(user, status): # can't find that status, idk return - fav_activity = activitypub.Undo(actor=user, object=favorite) + fav_activity = favorite.to_undo_activity(user) + favorite.delete() broadcast(user, fav_activity, direct_recipients=[status.user]) @@ -314,6 +325,24 @@ def handle_boost(user, status): boost_activity = boost.to_activity() broadcast(user, boost_activity) + create_notification( + status.user, + 'BOOST', + related_user=user, + related_status=status + ) + + +def handle_unboost(user, status): + ''' a user regrets boosting a status ''' + boost = models.Boost.objects.filter( + boosted_status=status, user=user + ).first() + activity = boost.to_undo_activity(user) + + boost.delete() + broadcast(user, activity) + def handle_update_book(user, book): ''' broadcast the news about our book ''' diff --git a/bookwyrm/remote_user.py b/bookwyrm/remote_user.py deleted file mode 100644 index 372f97a8c..000000000 --- a/bookwyrm/remote_user.py +++ /dev/null @@ -1,129 +0,0 @@ -''' manage remote users ''' -from urllib.parse import urlparse -from uuid import uuid4 -import requests - -from django.core.files.base import ContentFile -from django.db import transaction - -from bookwyrm import activitypub, models - - -def get_or_create_remote_user(actor): - ''' look up a remote user or add them ''' - try: - return models.User.objects.get(remote_id=actor) - except models.User.DoesNotExist: - pass - - data = fetch_user_data(actor) - - actor_parts = urlparse(actor) - with transaction.atomic(): - user = create_remote_user(data) - user.federated_server = get_or_create_remote_server(actor_parts.netloc) - user.save() - - avatar = get_avatar(data) - if avatar: - user.avatar.save(*avatar) - - if user.bookwyrm_user: - get_remote_reviews(user) - return user - - -def fetch_user_data(actor): - ''' load the user's info from the actor url ''' - response = requests.get( - actor, - headers={'Accept': 'application/activity+json'} - ) - if not response.ok: - response.raise_for_status() - data = response.json() - - # make sure our actor is who they say they are - if actor != data['id']: - raise ValueError("Remote actor id must match url.") - return data - - -def create_remote_user(data): - ''' parse the activitypub actor data into a user ''' - actor = activitypub.Person(**data) - return actor.to_model(models.User) - - -def refresh_remote_user(user): - ''' get updated user data from its home instance ''' - data = fetch_user_data(user.remote_id) - - activity = activitypub.Person(**data) - activity.to_model(models.User, instance=user) - - -def get_avatar(data): - ''' find the icon attachment and load the image from the remote sever ''' - icon_blob = data.get('icon') - if not icon_blob or not icon_blob.get('url'): - return None - - response = requests.get(icon_blob['url']) - if not response.ok: - return None - - image_name = str(uuid4()) + '.' + icon_blob['url'].split('.')[-1] - image_content = ContentFile(response.content) - return [image_name, image_content] - - -def get_remote_reviews(user): - ''' ingest reviews by a new remote bookwyrm user ''' - outbox_page = user.outbox + '?page=true' - response = requests.get( - outbox_page, - headers={'Accept': 'application/activity+json'} - ) - data = response.json() - # TODO: pagination? - for status in data['orderedItems']: - if status.get('bookwyrmType') == 'Review': - activitypub.Review(**status).to_model(models.Review) - - -def get_or_create_remote_server(domain): - ''' get info on a remote server ''' - try: - return models.FederatedServer.objects.get( - server_name=domain - ) - except models.FederatedServer.DoesNotExist: - pass - - response = requests.get( - 'https://%s/.well-known/nodeinfo' % domain, - headers={'Accept': 'application/activity+json'} - ) - - if response.status_code != 200: - return None - - data = response.json() - try: - nodeinfo_url = data.get('links')[0].get('href') - except (TypeError, KeyError): - return None - - response = requests.get( - nodeinfo_url, - headers={'Accept': 'application/activity+json'} - ) - data = response.json() - - server = models.FederatedServer.objects.create( - server_name=domain, - application_type=data['software']['name'], - application_version=data['software']['version'], - ) - return server diff --git a/bookwyrm/routine_book_tasks.py b/bookwyrm/routine_book_tasks.py deleted file mode 100644 index eaa28d905..000000000 --- a/bookwyrm/routine_book_tasks.py +++ /dev/null @@ -1,16 +0,0 @@ -''' Routine tasks for keeping your library tidy ''' -from datetime import timedelta -from django.utils import timezone -from bookwyrm import books_manager -from bookwyrm import models - -def sync_book_data(): - ''' update books with any changes to their canonical source ''' - expiry = timezone.now() - timedelta(days=1) - books = models.Edition.objects.filter( - sync=True, - last_sync_date__lte=expiry - ).all() - for book in books: - # TODO: create background tasks - books_manager.update_book(book) diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 6b162f9f2..3784158cc 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -6,6 +6,8 @@ from environs import Env env = Env() DOMAIN = env('DOMAIN') +PAGE_LENGTH = env('PAGE_LENGTH', 15) + # celery CELERY_BROKER = env('CELERY_BROKER') CELERY_RESULT_BACKEND = env('CELERY_RESULT_BACKEND') @@ -13,6 +15,13 @@ CELERY_ACCEPT_CONTENT = ['application/json'] CELERY_TASK_SERIALIZER = 'json' CELERY_RESULT_SERIALIZER = 'json' +# email +EMAIL_HOST = env('EMAIL_HOST') +EMAIL_PORT = env('EMAIL_PORT', 587) +EMAIL_HOST_USER = env('EMAIL_HOST_USER') +EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD') +EMAIL_USE_TLS = env('EMAIL_USE_TLS', True) + # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -38,6 +47,7 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.humanize', + 'django_rename_app', 'bookwyrm', 'celery', ] @@ -65,6 +75,7 @@ TEMPLATES = [ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', + 'bookwyrm.context_processors.site_settings', ], }, }, diff --git a/bookwyrm/signatures.py b/bookwyrm/signatures.py index 7e3c637ff..ff2816640 100644 --- a/bookwyrm/signatures.py +++ b/bookwyrm/signatures.py @@ -31,7 +31,7 @@ def make_signature(sender, destination, date, digest): 'digest: %s' % digest, ] message_to_sign = '\n'.join(signature_headers) - signer = pkcs1_15.new(RSA.import_key(sender.private_key)) + signer = pkcs1_15.new(RSA.import_key(sender.key_pair.private_key)) signed_message = signer.sign(SHA256.new(message_to_sign.encode('utf8'))) signature = { 'keyId': '%s#main-key' % sender.remote_id, @@ -44,7 +44,8 @@ def make_signature(sender, destination, date, digest): def make_digest(data): ''' creates a message digest for signing ''' - return 'SHA-256=' + b64encode(hashlib.sha256(data).digest()).decode('utf-8') + return 'SHA-256=' + b64encode(hashlib.sha256(data.encode('utf-8'))\ + .digest()).decode('utf-8') def verify_digest(request): diff --git a/bookwyrm/static/css/fonts/icomoon.eot b/bookwyrm/static/css/fonts/icomoon.eot index 5568c0ad4..30ae2cd57 100644 Binary files a/bookwyrm/static/css/fonts/icomoon.eot and b/bookwyrm/static/css/fonts/icomoon.eot differ diff --git a/bookwyrm/static/css/fonts/icomoon.svg b/bookwyrm/static/css/fonts/icomoon.svg index 4324502bb..aa0a9e5d4 100644 --- a/bookwyrm/static/css/fonts/icomoon.svg +++ b/bookwyrm/static/css/fonts/icomoon.svg @@ -7,30 +7,35 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bookwyrm/static/css/fonts/icomoon.ttf b/bookwyrm/static/css/fonts/icomoon.ttf index a61117da6..40d6e8862 100644 Binary files a/bookwyrm/static/css/fonts/icomoon.ttf and b/bookwyrm/static/css/fonts/icomoon.ttf differ diff --git a/bookwyrm/static/css/fonts/icomoon.woff b/bookwyrm/static/css/fonts/icomoon.woff index 1dccbcf96..6cfa9a4da 100644 Binary files a/bookwyrm/static/css/fonts/icomoon.woff and b/bookwyrm/static/css/fonts/icomoon.woff differ diff --git a/bookwyrm/static/css/format.css b/bookwyrm/static/css/format.css index c37131f46..9e8a24ba9 100644 --- a/bookwyrm/static/css/format.css +++ b/bookwyrm/static/css/format.css @@ -1,4 +1,7 @@ /* --- --- */ +.image { + overflow: hidden; +} .navbar .logo { max-height: 50px; } @@ -15,6 +18,10 @@ input.toggle-control:checked ~ .toggle-content { display: block; } +input.toggle-control:checked ~ .modal.toggle-content { + display: flex; +} + /* --- STARS --- */ .rate-stars button.icon { background: none; @@ -62,13 +69,26 @@ input.toggle-control:checked ~ .toggle-content { .cover-container.is-medium { height: 150px; } +.cover-container.is-small { + height: 100px; +} +@media only screen and (max-width: 768px) { + .cover-container { + height: 200px; + width: max-content; + } + .cover-container.is-medium { + height: 100px; + } + .cover-container.is-small { + height: 70px; + } +} + .cover-container.is-medium .no-cover div { font-size: 0.9em; padding: 0.3em; } -.cover-container.is-small { - height: 100px; -} .cover-container.is-small .no-cover div { font-size: 0.7em; padding: 0.1em; @@ -108,11 +128,16 @@ input.toggle-control:checked ~ .toggle-content { position: absolute; } .quote blockquote:before { - content: "\e904"; + content: "\e905"; top: 0; left: 0; } .quote blockquote:after { - content: "\e903"; + content: "\e904"; right: 0; } + +/* --- BLOCKQUOTE --- */ +blockquote { + white-space: pre-line; +} diff --git a/bookwyrm/static/css/icons.css b/bookwyrm/static/css/icons.css index eabfb06e3..536db5600 100644 --- a/bookwyrm/static/css/icons.css +++ b/bookwyrm/static/css/icons.css @@ -1,10 +1,10 @@ @font-face { font-family: 'icomoon'; - src: url('fonts/icomoon.eot?v0wquk'); - src: url('fonts/icomoon.eot?v0wquk#iefix') format('embedded-opentype'), - url('fonts/icomoon.ttf?v0wquk') format('truetype'), - url('fonts/icomoon.woff?v0wquk') format('woff'), - url('fonts/icomoon.svg?v0wquk#icomoon') format('svg'); + src: url('fonts/icomoon.eot?rd4abb'); + src: url('fonts/icomoon.eot?rd4abb#iefix') format('embedded-opentype'), + url('fonts/icomoon.ttf?rd4abb') format('truetype'), + url('fonts/icomoon.woff?rd4abb') format('woff'), + url('fonts/icomoon.svg?rd4abb#icomoon') format('svg'); font-weight: normal; font-style: normal; font-display: block; @@ -13,7 +13,7 @@ [class^="icon-"], [class*=" icon-"] { /* use !important to prevent issues with browser extensions that change fonts */ font-family: 'icomoon' !important; - speak: none; + speak: never; font-style: normal; font-weight: normal; font-variant: normal; @@ -25,26 +25,80 @@ -moz-osx-font-smoothing: grayscale; } -.icon-arrow-right:before { +.icon-dots-three-vertical:before { + content: "\e918"; +} +.icon-check:before { + content: "\e917"; +} +.icon-dots-three:before { + content: "\e916"; +} +.icon-envelope:before { content: "\e900"; } -.icon-arrow-left:before { - content: "\e910"; +.icon-arrow-right:before { + content: "\e901"; } -.icon-arrow-up:before { - content: "\e911"; -} -.icon-arrow-down:before { - content: "\e912"; +.icon-bell:before { + content: "\e902"; } .icon-x:before { - content: "\e902"; + content: "\e903"; } -.icon-cancel:before { - content: "\e902"; +.icon-quote-close:before { + content: "\e904"; } -.icon-close:before { - content: "\e902"; +.icon-quote-open:before { + content: "\e905"; +} +.icon-image:before { + content: "\e906"; +} +.icon-pencil:before { + content: "\e907"; +} +.icon-list:before { + content: "\e908"; +} +.icon-unlock:before { + content: "\e909"; +} +.icon-globe:before { + content: "\e90a"; +} +.icon-lock:before { + content: "\e90b"; +} +.icon-chain-broken:before { + content: "\e90c"; +} +.icon-chain:before { + content: "\e90d"; +} +.icon-comments:before { + content: "\e90e"; +} +.icon-comment:before { + content: "\e90f"; +} +.icon-boost:before { + content: "\e910"; +} +.icon-arrow-left:before { + content: "\e911"; +} +.icon-arrow-up:before { + content: "\e912"; +} +.icon-arrow-down:before { + content: "\e913"; +} +.icon-home:before { + content: "\e914"; +} +.icon-local:before { + content: "\e915"; } .icon-search:before { content: "\e986"; @@ -61,78 +115,6 @@ .icon-heart:before { content: "\e9da"; } -.icon-local:before { - content: "\e914"; -} -.icon-home:before { - content: "\e913"; -} -.icon-quote-close:before { - content: "\e903"; -} -.icon-quote-open:before { - content: "\e904"; -} -.icon-image:before { - content: "\e905"; -} -.icon-photo:before { - content: "\e905"; -} -.icon-picture-o:before { - content: "\e905"; -} -.icon-pencil:before { - content: "\e906"; -} -.icon-list:before { - content: "\e907"; -} -.icon-unlock:before { - content: "\e908"; -} -.icon-unlisted:before { - content: "\e908"; -} -.icon-globe:before { - content: "\e909"; -} -.icon-global:before { - content: "\e909"; -} -.icon-federated:before { - content: "\e909"; -} -.icon-public:before { - content: "\e909"; -} -.icon-lock:before { - content: "\e90a"; -} -.icon-private:before { - content: "\e90a"; -} -.icon-chain-broken:before { - content: "\e90b"; -} -.icon-unlink:before { - content: "\e90b"; -} -.icon-chain:before { - content: "\e90c"; -} -.icon-link:before { - content: "\e90c"; -} -.icon-comments:before { - content: "\e90d"; -} -.icon-comment:before { - content: "\e90e"; -} -.icon-boost:before { - content: "\e90f"; -} -.icon-bell:before { - content: "\e901"; +.icon-plus:before { + content: "\ea0a"; } diff --git a/bookwyrm/static/js/shared.js b/bookwyrm/static/js/shared.js index 9b16174e7..b99459b23 100644 --- a/bookwyrm/static/js/shared.js +++ b/bookwyrm/static/js/shared.js @@ -20,6 +20,11 @@ function reply(e) { return true; } +function selectAll(el) { + el.parentElement.querySelectorAll('[type="checkbox"]') + .forEach(t => t.checked=true); +} + function rate_stars(e) { e.preventDefault(); ajaxPost(e.target); @@ -31,20 +36,27 @@ function rate_stars(e) { return true; } -function tabChange(e) { +function tabChange(e, nested) { var target = e.target.closest('li') var identifier = target.getAttribute('data-id'); - var tabs = target.parentElement.children; - for (i = 0; i < tabs.length; i++) { - if (tabs[i].getAttribute('data-id') == identifier) { - tabs[i].className += ' is-active'; - } else { - tabs[i].className = tabs[i].className.replace('is-active', ''); - } + if (nested) { + var parent_element = target.parentElement.closest('li').parentElement; + } else { + var parent_element = target.parentElement; } - var el = document.getElementById(identifier); + parent_element.querySelectorAll('[aria-selected="true"]') + .forEach(t => t.setAttribute("aria-selected", false)); + target.querySelector('[role="tab"]').setAttribute("aria-selected", true); + + parent_element.querySelectorAll('li') + .forEach(t => t.className=''); + target.className = 'is-active'; +} + +function toggleMenu(el) { + el.setAttribute('aria-expanded', el.getAttribute('aria-expanded') == 'false'); } function ajaxPost(form) { diff --git a/bookwyrm/status.py b/bookwyrm/status.py index 0c13638e2..83a106e54 100644 --- a/bookwyrm/status.py +++ b/bookwyrm/status.py @@ -1,116 +1,28 @@ ''' Handle user activity ''' -from django.db import IntegrityError +from django.utils import timezone -from bookwyrm import models -from bookwyrm.books_manager import get_or_create_book +from bookwyrm import activitypub, books_manager, models from bookwyrm.sanitize_html import InputHtmlParser -def create_rating(user, book, rating): - ''' a review that's just a rating ''' - if not rating or rating < 1 or rating > 5: - raise ValueError('Invalid rating') - return models.Review.objects.create( - user=user, - book=book, - rating=rating, - ) +def delete_status(status): + ''' replace the status with a tombstone ''' + status.deleted = True + status.deleted_date = timezone.now() + status.save() -def create_review(user, book, name, content, rating): - ''' a book review has been added ''' - name = sanitize(name) - content = sanitize(content) - - # no ratings outside of 0-5 - if rating: - rating = rating if 1 <= rating <= 5 else None - else: - rating = None - - return models.Review.objects.create( - user=user, - book=book, - name=name, - rating=rating, - content=content, - ) - - -def create_quotation_from_activity(author, activity): - ''' parse an activity json blob into a status ''' - book_id = activity['inReplyToBook'] - book = get_or_create_book(book_id) - quote = activity.get('quote') - content = activity.get('content') - published = activity.get('published') - remote_id = activity['id'] - - quotation = create_quotation(author, book, content, quote) - quotation.published_date = published - quotation.remote_id = remote_id - quotation.save() - return quotation - - -def create_quotation(user, book, content, quote): - ''' a quotation has been added ''' - # throws a value error if the book is not found - content = sanitize(content) - quote = sanitize(quote) - - return models.Quotation.objects.create( - user=user, - book=book, - content=content, - quote=quote, - ) - - -def create_comment_from_activity(author, activity): - ''' parse an activity json blob into a status ''' - book_id = activity['inReplyToBook'] - book = get_or_create_book(book_id) - content = activity.get('content') - published = activity.get('published') - remote_id = activity['id'] - - comment = create_comment(author, book, content) - comment.published_date = published - comment.remote_id = remote_id - comment.save() - return comment - - -def create_comment(user, book, content): - ''' a book comment has been added ''' - # throws a value error if the book is not found - content = sanitize(content) - - return models.Comment.objects.create( - user=user, - book=book, - content=content, - ) - - -def get_status(remote_id): - ''' find a status in the database ''' - return models.Status.objects.select_subclasses().filter( - remote_id=remote_id - ).first() - - -def create_generated_note(user, content, mention_books=None): +def create_generated_note(user, content, mention_books=None, privacy='public'): ''' a note created by the app about user activity ''' # sanitize input html parser = InputHtmlParser() parser.feed(content) content = parser.get_output() - status = models.GeneratedStatus.objects.create( + status = models.GeneratedNote.objects.create( user=user, content=content, + privacy=privacy ) if mention_books: @@ -120,39 +32,6 @@ def create_generated_note(user, content, mention_books=None): return status -def create_status(user, content, reply_parent=None, mention_books=None): - ''' a status update ''' - # TODO: handle @'ing users - - # sanitize input html - parser = InputHtmlParser() - parser.feed(content) - content = parser.get_output() - - status = models.Status.objects.create( - user=user, - content=content, - reply_parent=reply_parent, - ) - - if mention_books: - for book in mention_books: - status.mention_books.add(book) - - return status - - -def create_tag(user, possible_book, name): - ''' add a tag to a book ''' - book = get_or_create_book(possible_book) - - try: - tag = models.Tag.objects.create(name=name, book=book, user=user) - except IntegrityError: - return models.Tag.objects.get(name=name, book=book, user=user) - return tag - - def create_notification(user, notification_type, related_user=None, \ related_book=None, related_status=None, related_import=None): ''' let a user know when someone interacts with their content ''' @@ -167,10 +46,3 @@ def create_notification(user, notification_type, related_user=None, \ related_import=related_import, notification_type=notification_type, ) - - -def sanitize(content): - ''' remove invalid html from free text ''' - parser = InputHtmlParser() - parser.feed(content) - return parser.get_output() diff --git a/bookwyrm/templates/about.html b/bookwyrm/templates/about.html index dbf6f8521..aa7426cad 100644 --- a/bookwyrm/templates/about.html +++ b/bookwyrm/templates/about.html @@ -3,14 +3,14 @@
- {% include 'snippets/about.html' with site_settings=site_settings %} + {% include 'snippets/about.html' %}

Code of Conduct

-

- {{ site_settings.code_of_conduct }} -

+
+ {{ site.code_of_conduct | safe }} +
{% endblock %} diff --git a/bookwyrm/templates/author.html b/bookwyrm/templates/author.html index cb05b9b71..3e3e00183 100644 --- a/bookwyrm/templates/author.html +++ b/bookwyrm/templates/author.html @@ -2,7 +2,7 @@ {% load fr_display %} {% block content %}
-

{{ author.display_name }}

+

{{ author.name }}

{% if author.bio %}

@@ -12,7 +12,7 @@

-

Books by {{ author.display_name }}

+

Books by {{ author.name }}

{% include 'snippets/book_tiles.html' with books=books %}
{% endblock %} diff --git a/bookwyrm/templates/book.html b/bookwyrm/templates/book.html index 5e9696fac..8b21b88ca 100644 --- a/bookwyrm/templates/book.html +++ b/bookwyrm/templates/book.html @@ -5,13 +5,13 @@
-

+

{% include 'snippets/book_titleby.html' with book=book %} -

+ {% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
- edit + Edit Book @@ -27,11 +27,18 @@ {% include 'snippets/shelve_button.html' %} {% if request.user.is_authenticated and not book.cover %} -
- {% csrf_token %} - {{ cover_form.as_p }} - -
+
+
+ {% csrf_token %} +
+ + +
+
+ +
+
+
{% endif %}
@@ -48,24 +55,135 @@

{% include 'snippets/stars.html' with rating=rating %} ({{ reviews|length }} review{{ reviews|length|pluralize }})

- {% include 'snippets/book_description.html' %} + {% include 'snippets/trimmed_text.html' with full=book|book_description %} - {% if book.parent_work.edition_set.count > 1 %} -

{{ book.parent_work.edition_set.count }} editions

+ {% if request.user.is_authenticated and perms.bookwyrm.edit_book and not book|book_description %} +
+ + +
+ +
+ + +
+ {% endif %} + + + {% if book.parent_work.editions.count > 1 %} +

{{ book.parent_work.editions.count }} editions

{% endif %}
- {% if request.user.is_authenticated %} + {% for readthrough in readthroughs %} +
+ + +
+
+ + +
+ +
+ + +
+ {% endfor %} + + {% if request.user.is_authenticated %} +
{% include 'snippets/create_status.html' with book=book hide_cover=True %}
-

Tags

+ {% csrf_token %} - +
diff --git a/bookwyrm/templates/book_results.html b/bookwyrm/templates/book_results.html deleted file mode 100644 index 71d3a637c..000000000 --- a/bookwyrm/templates/book_results.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends 'layout.html' %} -{% block content %} -
-

Search results

- {% for result_set in results %} - {% if result_set.results %} -
- {% if not result_set.connector.local %} -

- Results from {% if result_set.connector.name %}{{ result_set.connector.name }}{% else %}{{ result_set.connector.identifier }}{% endif %} -

- {% endif %} - - {% for result in result_set.results %} -
-
- {% csrf_token %} - - -
-
- {% endfor %} -
- {% endif %} - {% endfor %} -
-{% endblock %} diff --git a/bookwyrm/templates/edit_book.html b/bookwyrm/templates/edit_book.html index 5afe192d7..54cefb0a7 100644 --- a/bookwyrm/templates/edit_book.html +++ b/bookwyrm/templates/edit_book.html @@ -3,9 +3,9 @@ {% block content %}
-

+

Edit "{{ book.title }}" -

+
-
-
- {% include 'snippets/book_cover.html' with book=book size="small" %} -
-
-

Added: {{ book.created_date | naturaltime }}

-

Updated: {{ book.updated_date | naturaltime }}

-
+
+

Added: {{ book.created_date | naturaltime }}

+

Updated: {{ book.updated_date | naturaltime }}

-
+{% if login_form.non_field_errors %} +
+

{{ login_form.non_field_errors }}

+
+{% endif %} + + {% csrf_token %}
-

Data sync

-

If sync is enabled, any changes will be over-written

- +

Data sync +

If sync is enabled, any changes will be over-written

+

@@ -42,44 +43,104 @@
-
-

Book Identifiers

-

{{ form.isbn_13 }}

-

{{ form.isbn_10 }}

-

{{ form.openlibrary_key }}

-

{{ form.librarything_key }}

-

{{ form.goodreads_key }}

+
+

Metadata

+

{{ form.title }}

+ {% for error in form.title.errors %} +

{{ error | escape }}

+ {% endfor %} +

{{ form.sort_title }}

+ {% for error in form.sort_title.errors %} +

{{ error | escape }}

+ {% endfor %} +

{{ form.subtitle }}

+ {% for error in form.subtitle.errors %} +

{{ error | escape }}

+ {% endfor %} +

{{ form.description }}

+ {% for error in form.description.errors %} +

{{ error | escape }}

+ {% endfor %} +

{{ form.series }}

+ {% for error in form.series.errors %} +

{{ error | escape }}

+ {% endfor %} +

{{ form.series_number }}

+ {% for error in form.series_number.errors %} +

{{ error | escape }}

+ {% endfor %} +

{{ form.first_published_date }}

+ {% for error in form.first_published_date.errors %} +

{{ error | escape }}

+ {% endfor %} +

{{ form.published_date }}

+ {% for error in form.published_date.errors %} +

{{ error | escape }}

+ {% endfor %}
-
-

Cover

-

{{ form.cover }}

+
+
+ {% include 'snippets/book_cover.html' with book=book size="small" %} +
+
+
+

Cover

+

{{ form.cover }}

+ {% for error in form.cover.errors %} +

{{ error | escape }}

+ {% endfor %} +
+
-

Physical Properties

+

Physical Properties

{{ form.physical_format }}

+ {% for error in form.physical_format.errors %} +

{{ error | escape }}

+ {% endfor %} + {% for error in form.physical_format.errors %} +

{{ error | escape }}

+ {% endfor %} +

{{ form.pages }}

+ {% for error in form.pages.errors %} +

{{ error | escape }}

+ {% endfor %} +
+ +
+

Book Identifiers

+

{{ form.isbn_13 }}

+ {% for error in form.isbn_13.errors %} +

{{ error | escape }}

+ {% endfor %} +

{{ form.isbn_10 }}

+ {% for error in form.isbn_10.errors %} +

{{ error | escape }}

+ {% endfor %} +

{{ form.openlibrary_key }}

+ {% for error in form.openlibrary_key.errors %} +

{{ error | escape }}

+ {% endfor %} +

{{ form.librarything_key }}

+ {% for error in form.librarything_key.errors %} +

{{ error | escape }}

+ {% endfor %} +

{{ form.goodreads_key }}

+ {% for error in form.goodreads_key.errors %} +

{{ error | escape }}

+ {% endfor %}
-
-

Metadata

-

{{ form.title }}

-

{{ form.sort_title }}

-

{{ form.subtitle }}

-

{{ form.description }}

-

{{ form.series }}

-

{{ form.series_number }}

-

{{ form.first_published_date }}

-

{{ form.published_date }}

-
+ Cancel
{% endblock %} - diff --git a/bookwyrm/templates/edit_user.html b/bookwyrm/templates/edit_user.html index a95cbca2f..7e963b5b1 100644 --- a/bookwyrm/templates/edit_user.html +++ b/bookwyrm/templates/edit_user.html @@ -2,11 +2,11 @@ {% block content %}
-

Profile

+

Profile

{% if form.non_field_errors %}

{{ form.non_field_errors }}

{% endif %} -
+ {% csrf_token %}

@@ -37,7 +37,7 @@ {% endfor %}

-

-

Editions of "{{ work.title }}"

+

Editions of "{{ work.title }}"

{% include 'snippets/book_tiles.html' with books=editions %}
diff --git a/bookwyrm/templates/error.html b/bookwyrm/templates/error.html index 5169c63e4..6405dc5af 100644 --- a/bookwyrm/templates/error.html +++ b/bookwyrm/templates/error.html @@ -2,7 +2,7 @@ {% block content %}
-

Server Error

+

Server Error

Something went wrong! Sorry about that.

diff --git a/bookwyrm/templates/feed.html b/bookwyrm/templates/feed.html index 3003f5187..6e49943af 100644 --- a/bookwyrm/templates/feed.html +++ b/bookwyrm/templates/feed.html @@ -4,23 +4,46 @@
-

Suggested books

+

Your books

{% if not suggested_books %}

There are no books here right now! Try searching for a book to get started

{% else %} -
+
- {% for book in suggested_books %} + {% for shelf in suggested_books %} + {% with shelf_counter=forloop.counter %} + {% for book in shelf.books %}
- -
+

{{ tab | title }} Timeline

  • diff --git a/bookwyrm/templates/followers.html b/bookwyrm/templates/followers.html index 094574083..645e46a17 100644 --- a/bookwyrm/templates/followers.html +++ b/bookwyrm/templates/followers.html @@ -1,7 +1,17 @@ {% extends 'layout.html' %} {% load fr_display %} {% block content %} -{% include 'user_header.html' with user=user %} +
    +

    + {% if is_self %}Your + {% else %} + {% include 'snippets/username.html' with user=user possessive=True %} + {% endif %} + followers +

    +
    + +{% include 'snippets/user_header.html' with user=user %}

    Followers

    diff --git a/bookwyrm/templates/following.html b/bookwyrm/templates/following.html index 9131adea1..2cca91273 100644 --- a/bookwyrm/templates/following.html +++ b/bookwyrm/templates/following.html @@ -1,7 +1,17 @@ {% extends 'layout.html' %} {% load fr_display %} {% block content %} -{% include 'user_header.html' %} +
    +

    + Users following + {% if is_self %}you + {% else %} + {% include 'snippets/username.html' with user=user %} + {% endif %} +

    +
    + +{% include 'snippets/user_header.html' with user=user %}

    Following

    diff --git a/bookwyrm/templates/import.html b/bookwyrm/templates/import.html index 3b8708bed..8e3f5eb49 100644 --- a/bookwyrm/templates/import.html +++ b/bookwyrm/templates/import.html @@ -2,11 +2,24 @@ {% load humanize %} {% block content %}
    -

    Import Books from GoodReads

    - +

    Import Books from GoodReads

    + {% csrf_token %} - {{ import_form.as_p }} - +
    + {{ import_form.as_p }} +
    +
    + +
    +
    + +
    +

    Imports are limited in size, and only the first {{ limit }} items will be imported. @@ -19,7 +32,7 @@ {% endif %}

    diff --git a/bookwyrm/templates/import_status.html b/bookwyrm/templates/import_status.html index 7db2ba3d5..f91e2cce5 100644 --- a/bookwyrm/templates/import_status.html +++ b/bookwyrm/templates/import_status.html @@ -6,29 +6,79 @@

    Import Status

    - Import started: {{ job.created_date | naturaltime }} + Import started: {{ job.created_date | naturaltime }} +

    + {% if task.successful %}

    - {% if task.ready %} - Import completed: {{ task.date_done | naturaltime }} - {% if task.failed %} -

    TASK FAILED

    -

    - {{ task.info }} + Import completed: {{ task.date_done | naturaltime }} +

    + {% elif task.failed %} +
    TASK FAILED
    {% endif %}
    - {% if job.import_status %} - {% include 'snippets/status.html' with status=job.import_status %} - {% endif %} - {% else %} + {% if not task.ready %} Import still in progress.

    - (Hit reload to update!) + (Hit reload to update!) +

    {% endif %}
    +{% if failed_items %}
    +

    Failed to load

    + {% if not job.retry %} +
    + {% csrf_token %} + +
      +
      + {% for item in failed_items %} +
    • + + +

      + {{ item.fail_reason }}. +

      +
    • + {% endfor %} +
      +
    +
    + +
    + + {% else %} +
      + {% for item in failed_items %} +
    • +

      + Line {{ item.index }}: + {{ item.data|dict_key:'Title' }} by + {{ item.data|dict_key:'Author' }} +

      +

      + {{ item.fail_reason }}. +

      +
    • + {% endfor %} +
    + {% endif %} +
    +
    +{% endif %} + +
    +

    Successfully imported

    diff --git a/bookwyrm/templates/invite.html b/bookwyrm/templates/invite.html index 939281d86..42f564d6b 100644 --- a/bookwyrm/templates/invite.html +++ b/bookwyrm/templates/invite.html @@ -4,7 +4,7 @@
    diff --git a/bookwyrm/templates/layout.html b/bookwyrm/templates/layout.html index f14b76aae..b37c9cda1 100644 --- a/bookwyrm/templates/layout.html +++ b/bookwyrm/templates/layout.html @@ -2,7 +2,7 @@ - BookWyrm + {% if title %}{{ title }} | {% endif %}{{ site.name }} @@ -11,35 +11,39 @@ - - + + - -
    @@ -59,9 +109,10 @@ {{ item.data|dict_key:'Author' }} - {% if item.book %}✓ - {% elif item.fail_reason %} - {{ item.fail_reason }} + {% if item.book %} + + Imported + {% endif %}
    @@ -27,23 +27,20 @@

    Generate New Invite

    - + {% csrf_token %} -
    +
    +
    + {{ form.expiry }} +
    -
    - {{ form.expiry }} -
    -
    - -
    -
    -
    - {{ form.use_limit }} +
    + {{ form.use_limit }} +
    diff --git a/bookwyrm/templates/notfound.html b/bookwyrm/templates/notfound.html index 23a771923..73fe54c24 100644 --- a/bookwyrm/templates/notfound.html +++ b/bookwyrm/templates/notfound.html @@ -2,7 +2,7 @@ {% block content %}
    -

    Not Found

    +

    Not Found

    The page your requested doesn't seem to exist!

    diff --git a/bookwyrm/templates/notifications.html b/bookwyrm/templates/notifications.html index e59810b9a..f9494c31d 100644 --- a/bookwyrm/templates/notifications.html +++ b/bookwyrm/templates/notifications.html @@ -1,20 +1,22 @@ {% extends 'layout.html' %} -{% load humanize %}l +{% load humanize %} +{% load fr_display %} {% block content %}
    -

    Notifications

    +

    Notifications

    {% csrf_token %} - +
    {% for notification in notifications %} -
    -
    +
    +

    + {# DESCRIPTION #} {% if notification.related_user %} {% include 'snippets/avatar.html' with user=notification.related_user %} {% include 'snippets/username.html' with user=notification.related_user %} @@ -22,14 +24,16 @@ favorited your status + {% elif notification.notification_type == 'MENTION' %} + mentioned you in a + status + {% elif notification.notification_type == 'REPLY' %} replied to your status - {% elif notification.notification_type == 'FOLLOW' %} followed you - {% elif notification.notification_type == 'FOLLOW_REQUEST' %} sent you a follow request

    @@ -40,19 +44,31 @@ boosted your status {% endif %} {% else %} - your import completed. - + your import completed. {% endif %}

    - -

    {{ notification.created_date | naturaltime }}

    + {% if notification.related_status %} +
    + {# PREVIEW #} +
    +
    + +
    + {{ notification.related_status.published_date | post_date }} + {% include 'snippets/privacy-icons.html' with item=notification.related_status %} +
    +
    +
    +
    + {% endif %}
    {% endfor %} + {% if not notifications %}

    You're all caught up!

    {% endif %}
    - {% endblock %} - diff --git a/bookwyrm/templates/password_reset.html b/bookwyrm/templates/password_reset.html index ca3ae5005..99d114e46 100644 --- a/bookwyrm/templates/password_reset.html +++ b/bookwyrm/templates/password_reset.html @@ -4,7 +4,7 @@
    -

    Reset Password

    +

    Reset Password

    {% for error in errors %}

    {{ error }}

    {% endfor %} @@ -34,7 +34,7 @@
    - {% include 'snippets/about.html' with site_settings=site_settings %} + {% include 'snippets/about.html' %}
    diff --git a/bookwyrm/templates/password_reset_request.html b/bookwyrm/templates/password_reset_request.html index f66d84a9b..2c15b189e 100644 --- a/bookwyrm/templates/password_reset_request.html +++ b/bookwyrm/templates/password_reset_request.html @@ -4,7 +4,7 @@
    -

    Reset Password

    +

    Reset Password

    {% if message %}

    {{ message }}

    {% endif %}

    A link to reset your password will be sent to your email address

    @@ -17,7 +17,7 @@
    - +
    diff --git a/bookwyrm/templates/search_results.html b/bookwyrm/templates/search_results.html new file mode 100644 index 000000000..46828fb0f --- /dev/null +++ b/bookwyrm/templates/search_results.html @@ -0,0 +1,88 @@ +{% extends 'layout.html' %} +{% block content %} +{% with book_results|first as local_results %} +
    +

    Search Results for "{{ query }}"

    +
    + +
    +
    +

    Matching Books

    +
    + {% if not local_results.results %} +

    No books found for "{{ query }}"

    + {% else %} + + {% endif %} +
    + + {% if book_results|slice:":1" and local_results.results and request.user.is_authenticated %} +
    +

    + Didn't find what you were looking for? +

    + + + +
    + {% endif %} + + + +
    +
    +

    Matching Users

    + {% if not user_results %} +

    No users found for "{{ query }}"

    + {% endif %} + {% for result in user_results %} +
    + {% include 'snippets/avatar.html' with user=result %} + {% include 'snippets/username.html' with user=result show_full=True %} + {% include 'snippets/follow_button.html' with user=result %} +
    + {% endfor %} +
    +
    +{% endwith %} +{% endblock %} diff --git a/bookwyrm/templates/shelf.html b/bookwyrm/templates/shelf.html index 2c49f0024..d6842d13f 100644 --- a/bookwyrm/templates/shelf.html +++ b/bookwyrm/templates/shelf.html @@ -1,19 +1,127 @@ {% extends 'layout.html' %} +{% load fr_display %} {% block content %} -
    - +
    +
    +

    + {% if is_self %}Your + {% else %} + {% include 'snippets/username.html' with user=user possessive=True %} + {% endif %} + shelves +

    +
    +
    + +{% include 'snippets/user_header.html' with user=user %} + +
    +
    +
    + +
    +
    + + {% if is_self %} +
    + + +
    + {% endif %} +
    + + + + +
    +
    +

    + {{ shelf.name }} + + {% include 'snippets/privacy-icons.html' with item=shelf %} + +

    +
    + {% if is_self %} +
    + + +
    + {% endif %} +
    + + +
    -

    {{ shelf.name }}

    {% include 'snippets/shelf.html' with shelf=shelf ratings=ratings %}
    diff --git a/bookwyrm/templates/snippets/about.html b/bookwyrm/templates/snippets/about.html index 926982068..9660f521b 100644 --- a/bookwyrm/templates/snippets/about.html +++ b/bookwyrm/templates/snippets/about.html @@ -1,11 +1,7 @@ -

    About {{ site_settings.name }}

    +

    About {{ site.name }}

    BookWyrm

    - {{ site_settings.instance_description }} -

    - -

    - More about this site + {{ site.instance_description }}

    diff --git a/bookwyrm/templates/snippets/authors.html b/bookwyrm/templates/snippets/authors.html index 165c4cdaa..e8106f5d6 100644 --- a/bookwyrm/templates/snippets/authors.html +++ b/bookwyrm/templates/snippets/authors.html @@ -1 +1 @@ -{{ book.authors.first.display_name }} +{{ book.authors.first.name }} diff --git a/bookwyrm/templates/snippets/avatar.html b/bookwyrm/templates/snippets/avatar.html index ab621777f..cb0a12ea7 100644 --- a/bookwyrm/templates/snippets/avatar.html +++ b/bookwyrm/templates/snippets/avatar.html @@ -1,2 +1,3 @@ - +{% load fr_display %} +avatar for {{ user|username }} diff --git a/bookwyrm/templates/snippets/book_description.html b/bookwyrm/templates/snippets/book_description.html deleted file mode 100644 index f5fb3f439..000000000 --- a/bookwyrm/templates/snippets/book_description.html +++ /dev/null @@ -1,6 +0,0 @@ -{% if book.description %} -
    {{ book.description }}
    -{% elif book.parent_work.description %} -
    {{ book.parent_work.description }}
    -{% endif %} - diff --git a/bookwyrm/templates/snippets/book_preview.html b/bookwyrm/templates/snippets/book_preview.html new file mode 100644 index 000000000..c675c45f0 --- /dev/null +++ b/bookwyrm/templates/snippets/book_preview.html @@ -0,0 +1,13 @@ +{% load fr_display %} +
    +
    +
    + {% include 'snippets/book_cover.html' with book=book %} + {% include 'snippets/shelve_button.html' with book=book %} +
    +
    +
    +

    {% include 'snippets/book_titleby.html' with book=book %}

    + {% include 'snippets/trimmed_text.html' with full=book|book_description %} +
    +
    diff --git a/bookwyrm/templates/snippets/boost_button.html b/bookwyrm/templates/snippets/boost_button.html new file mode 100644 index 000000000..57765bed1 --- /dev/null +++ b/bookwyrm/templates/snippets/boost_button.html @@ -0,0 +1,19 @@ +{% load fr_display %} +{% with status.id|uuid as uuid %} +
    + {% csrf_token %} + + +
    + {% csrf_token %} + + +{% endwith %} diff --git a/bookwyrm/templates/snippets/create_status.html b/bookwyrm/templates/snippets/create_status.html index 88558df9c..e36b1b194 100644 --- a/bookwyrm/templates/snippets/create_status.html +++ b/bookwyrm/templates/snippets/create_status.html @@ -1,79 +1,43 @@ {% load humanize %} {% load fr_display %} -
    -
    -
    - -
    - -
    - - - {% csrf_token %} - -
    - - +
    +
      +
    • +
    • +
    • +
    - -
    - - - {% csrf_token %} - -
    - - + + +
  • +
  • - -
    - - - {% csrf_token %} - -
    - - -
    -
    - - -
    - - -
    -
    + + + +
    + +
    + + {% include 'snippets/create_status_form.html' with type='review' %} +
    + +
    + + {% include 'snippets/create_status_form.html' with type="comment" placeholder="Some thoughts on '"|add:book.title|add:"'" %} +
    + +
    + + {% include 'snippets/create_status_form.html' with type="quote" placeholder="An excerpt from '"|add:book.title|add:"'" %}
    diff --git a/bookwyrm/templates/snippets/create_status_form.html b/bookwyrm/templates/snippets/create_status_form.html new file mode 100644 index 000000000..d6aa3fb3c --- /dev/null +++ b/bookwyrm/templates/snippets/create_status_form.html @@ -0,0 +1,47 @@ + + {% csrf_token %} + + + {% if type == 'review' %} +
    + + +
    + {% endif %} +
    + + + {% if type == 'review' %} +
    + Rating +
    + + + {% for i in '12345'|make_list %} + + + {% endfor %} +
    +
    + {% endif %} + {% if type == 'quote' %} + + {% else %} + + {% endif %} + +
    + {% if type == 'quote' %} +
    + + +
    + {% endif %} +
    + {% include 'snippets/privacy_select.html' %} + +
    + + diff --git a/bookwyrm/templates/snippets/fav_button.html b/bookwyrm/templates/snippets/fav_button.html new file mode 100644 index 000000000..58ece1e46 --- /dev/null +++ b/bookwyrm/templates/snippets/fav_button.html @@ -0,0 +1,19 @@ +{% load fr_display %} +{% with status.id|uuid as uuid %} +
    + {% csrf_token %} + + +
    + {% csrf_token %} + + +{% endwith %} diff --git a/bookwyrm/templates/snippets/finish_reading_modal.html b/bookwyrm/templates/snippets/finish_reading_modal.html new file mode 100644 index 000000000..8f8aeeff6 --- /dev/null +++ b/bookwyrm/templates/snippets/finish_reading_modal.html @@ -0,0 +1,49 @@ +{% load fr_display %} +
    + + +
    + diff --git a/bookwyrm/templates/snippets/follow_button.html b/bookwyrm/templates/snippets/follow_button.html index 5eea5bbfb..65df19e86 100644 --- a/bookwyrm/templates/snippets/follow_button.html +++ b/bookwyrm/templates/snippets/follow_button.html @@ -11,14 +11,14 @@ Follow request already sent. {% csrf_token %} {% if user.manually_approves_followers %} - + {% else %} - + {% endif %} {% csrf_token %} - + {% endif %} diff --git a/bookwyrm/templates/snippets/follow_request_buttons.html b/bookwyrm/templates/snippets/follow_request_buttons.html index 165887e0d..e1b806312 100644 --- a/bookwyrm/templates/snippets/follow_request_buttons.html +++ b/bookwyrm/templates/snippets/follow_request_buttons.html @@ -1,13 +1,13 @@ {% load fr_display %} {% if request.user|follow_request_exists:user %} -
    + {% csrf_token %} - + -
    + {% csrf_token %} - + {% endif %} diff --git a/bookwyrm/templates/snippets/interaction.html b/bookwyrm/templates/snippets/interaction.html deleted file mode 100644 index 8680df8de..000000000 --- a/bookwyrm/templates/snippets/interaction.html +++ /dev/null @@ -1,65 +0,0 @@ -{% load fr_display %} - diff --git a/bookwyrm/templates/snippets/privacy-icons.html b/bookwyrm/templates/snippets/privacy-icons.html new file mode 100644 index 000000000..b911f1281 --- /dev/null +++ b/bookwyrm/templates/snippets/privacy-icons.html @@ -0,0 +1,18 @@ +{% if item.privacy == 'public' %} + + Public post + +{% elif item.privacy == 'unlisted' %} + + Unlisted post + +{% elif item.privacy == 'followers' %} + + Followers-only post + +{% else %} + + Private post + +{% endif %} + diff --git a/bookwyrm/templates/snippets/privacy_select.html b/bookwyrm/templates/snippets/privacy_select.html new file mode 100644 index 000000000..c8d974c02 --- /dev/null +++ b/bookwyrm/templates/snippets/privacy_select.html @@ -0,0 +1,23 @@ +{% load fr_display %} +
    + {% with 0|uuid as uuid %} + {% if not no_label %} + + {% endif %} + + {% endwith %} +
    + diff --git a/bookwyrm/templates/snippets/reply_form.html b/bookwyrm/templates/snippets/reply_form.html new file mode 100644 index 000000000..2d8abd232 --- /dev/null +++ b/bookwyrm/templates/snippets/reply_form.html @@ -0,0 +1,26 @@ +{% load fr_display %} +{% with activity.id|uuid as uuid %} +
    +
    + {% csrf_token %} + + +
    +
    + +
    +
    + +
    +
    + {% include 'snippets/privacy_select.html' %} +
    +
    + +
    +
    +
    + +{% endwith %} diff --git a/bookwyrm/templates/snippets/search_result_text.html b/bookwyrm/templates/snippets/search_result_text.html new file mode 100644 index 000000000..183b1ec8d --- /dev/null +++ b/bookwyrm/templates/snippets/search_result_text.html @@ -0,0 +1,2 @@ +{% if link %}{{ result.title }}{% else %}{{ result.title }}{% endif %} +{% if result.author %} by {{ result.author }}{% endif %}{% if result.year %} ({{ result.year }}){% endif %} diff --git a/bookwyrm/templates/snippets/shelf.html b/bookwyrm/templates/snippets/shelf.html index 8f825f5f5..4e41cd303 100644 --- a/bookwyrm/templates/snippets/shelf.html +++ b/bookwyrm/templates/snippets/shelf.html @@ -1,6 +1,6 @@ {% load humanize %} {% load fr_display %} -{% if shelf.books %} +{% if shelf.books.all|length > 0 %}
    Link
    @@ -43,7 +43,7 @@ {{ book.title }} {% endif %} + {% if shelf.user == request.user %} + + {% endif %} {% endfor %}
    - {{ book.authors.first.display_name }} + {{ book.authors.first.name }} {% if book.first_published_date %}{{ book.first_published_date }}{% endif %} @@ -66,10 +66,25 @@ {% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %} + {% include 'snippets/shelf_selector.html' with current=shelf %} +
    {% else %}

    This shelf is empty.

    +{% if shelf.editable %} +
    + {% csrf_token %} + + +
    +{% endif %} + {% endif %} diff --git a/bookwyrm/templates/snippets/shelf_selector.html b/bookwyrm/templates/snippets/shelf_selector.html new file mode 100644 index 000000000..cf82bcb58 --- /dev/null +++ b/bookwyrm/templates/snippets/shelf_selector.html @@ -0,0 +1,36 @@ + diff --git a/bookwyrm/templates/snippets/shelve_button.html b/bookwyrm/templates/snippets/shelve_button.html index c5b7aa0ea..857f0f9c8 100644 --- a/bookwyrm/templates/snippets/shelve_button.html +++ b/bookwyrm/templates/snippets/shelve_button.html @@ -1,34 +1,67 @@ {% load fr_display %} {% if request.user.is_authenticated %} +{% with book.id|uuid as uuid %} +{% active_shelf book as active_shelf %}
    -
    - {% csrf_token %} - - - -
    - - {% endif %} -
    -
    +{% endwith %} {% endif %} diff --git a/bookwyrm/templates/snippets/stars.html b/bookwyrm/templates/snippets/stars.html index 989c015ca..b73a9b2bc 100644 --- a/bookwyrm/templates/snippets/stars.html +++ b/bookwyrm/templates/snippets/stars.html @@ -1,5 +1,5 @@
    - {{ rating|floatformat }} star{{ rating|floatformat | pluralize }} + {% if rating %}{{ rating|floatformat }} star{{ rating|floatformat | pluralize }}{% else %}No rating{% endif %} {% for i in '12345'|make_list %} diff --git a/bookwyrm/templates/snippets/start_reading_modal.html b/bookwyrm/templates/snippets/start_reading_modal.html new file mode 100644 index 000000000..4518af4ff --- /dev/null +++ b/bookwyrm/templates/snippets/start_reading_modal.html @@ -0,0 +1,39 @@ +
    + + +
    diff --git a/bookwyrm/templates/snippets/status.html b/bookwyrm/templates/snippets/status.html index f1fab7427..a7f6a3920 100644 --- a/bookwyrm/templates/snippets/status.html +++ b/bookwyrm/templates/snippets/status.html @@ -1,31 +1,11 @@ -{% load humanize %} {% load fr_display %} - -
    -
    - {% include 'snippets/status_header.html' with status=status %} -
    - -
    - {% if status.status_type == 'Boost' %} - {% include 'snippets/status_content.html' with status=status.boosted_status %} - {% else %} - {% include 'snippets/status_content.html' with status=status %} - {% endif %} -
    - -
    - {% if status.status_type == 'Boost' %} - {% include 'snippets/interaction.html' with activity=status.boosted_status %} - {% else %} - {% include 'snippets/interaction.html' with activity=status %} - {% endif %} - - -
    -
    +{% if not status.deleted %} + {% if status.status_type == 'Boost' %} + {% include 'snippets/avatar.html' with user=status.user %} + {% include 'snippets/username.html' with user=status.user %} + boosted + {% include 'snippets/status_body.html' with status=status|boosted_status %} + {% else %} + {% include 'snippets/status_body.html' with status=status %} + {% endif %} +{% endif %} diff --git a/bookwyrm/templates/snippets/status_body.html b/bookwyrm/templates/snippets/status_body.html new file mode 100644 index 000000000..4ef763fb4 --- /dev/null +++ b/bookwyrm/templates/snippets/status_body.html @@ -0,0 +1,109 @@ +{% load fr_display %} +{% load humanize %} + +{% if not status.deleted %} +
    +
    +
    +
    +
    + {% include 'snippets/status_header.html' with status=status %} +
    +
    +
    +
    + +
    + {% include 'snippets/status_content.html' with status=status %} +
    + +
    + + + {% if request.user.is_authenticated %} + + + {% endif %} + + {% if status.user == request.user %} +
    + + +
    + {% endif %} +
    +
    +{% else %} +
    +
    +

    + {% include 'snippets/avatar.html' with user=status.user %} + {% include 'snippets/username.html' with user=status.user %} + deleted this status +

    +
    +
    +{% endif %} diff --git a/bookwyrm/templates/snippets/status_content.html b/bookwyrm/templates/snippets/status_content.html index 595870498..e71588ad1 100644 --- a/bookwyrm/templates/snippets/status_content.html +++ b/bookwyrm/templates/snippets/status_content.html @@ -1,64 +1,48 @@ {% load fr_display %} +
    + {% if status.status_type == 'Review' %} +

    + {% if status.name %}{{ status.name }}
    {% endif %} + {% include 'snippets/stars.html' with rating=status.rating %} +

    + {% endif %} -
    - {% if not hide_book and status.mention_books.count %} -
    + {% if status.quote %} +
    +
    {{ status.quote }}
    + +

    — {% include 'snippets/book_titleby.html' with book=status.book %}

    +
    + {% endif %} + + {% if status.content and status.status_type != 'GeneratedNote' and status.status_type != 'Boost' %} + {% include 'snippets/trimmed_text.html' with full=status.content|safe %} + {% endif %} + {% if status.attachments %} +
    - {% for book in status.mention_books.all|slice:"0:4" %} -
    - {% include 'snippets/book_cover.html' with book=book %} - {% if status.mention_books.count > 1 %} -

    {% include 'snippets/book_titleby.html' with book=book %}

    - {% endif %} - {% include 'snippets/shelve_button.html' with book=book %} + {% for attachment in status.attachments.all %} +
    +
    + + {{ attachment.caption }} + +
    - {% endfor %} + {% endfor %}
    {% endif %} - - {% if not hide_book and status.book %} -
    -
    - {% include 'snippets/book_cover.html' with book=status.book %} - {% include 'snippets/shelve_button.html' with book=status.book %} -
    -
    - {% endif %} - -
    - {% if status.status_type == 'Review' %} -

    - {% if status.name %}{{ status.name }}
    {% endif %} - {% include 'snippets/stars.html' with rating=status.rating %} -

    - {% endif %} - - {% if status.quote %} -
    -
    {{ status.quote }}
    - -

    — {% include 'snippets/book_titleby.html' with book=status.book %}

    -
    - {% endif %} - - {% if status.content and status.status_type != 'GeneratedNote' and status.status_type != 'Boost' %} -
    {{ status.content | safe }}
    - {% endif %} - - {% if status.mention_books.count == 1 and not status.book %} - {% include 'snippets/book_description.html' with book=status.mention_books.first %} - {% endif %} - - {% if not status.content and status.book and not hide_book and status.status_type != 'Boost' %} - {% include 'snippets/book_description.html' with book=status.book %} - {% endif %} - - {% if status.status_type == 'Boost' %} - {% include 'snippets/status_content.html' with status=status|boosted_status %} - {% endif %} - - {% if not max_depth and status.reply_parent or status|replies %}

    Thread{% endif %} -

    +{% if not hide_book %} +{% if status.book or status.mention_books.count %} +
    + {% if status.book %} + {% include 'snippets/book_preview.html' with book=status.book %} + {% elif status.mention_books.count %} + {% include 'snippets/book_preview.html' with book=status.mention_books.first %} + {% endif %} +
    +{% endif %} +{% endif %} diff --git a/bookwyrm/templates/snippets/status_header.html b/bookwyrm/templates/snippets/status_header.html index 2d2421cb0..1e4179adc 100644 --- a/bookwyrm/templates/snippets/status_header.html +++ b/bookwyrm/templates/snippets/status_header.html @@ -1,25 +1,24 @@ {% load fr_display %} -
    -

    - {% include 'snippets/avatar.html' with user=status.user %} +{% include 'snippets/avatar.html' with user=status.user %} +{% include 'snippets/username.html' with user=status.user %} - {% include 'snippets/username.html' with user=status.user %} - {% if status.status_type == 'GeneratedNote' %} - {{ status.content | safe }} {% include 'snippets/book_titleby.html' with book=status.mention_books.first %} - {% elif status.status_type == 'Boost' %} - boosted {% include 'snippets/avatar.html' with user=status.boosted_status.user %}{% include 'snippets/username.html' with user=status.boosted_status.user possessive=True %} status - {% elif status.status_type == 'Review' and not status.name and not status.content%} - rated {{ status.book.title }} - {% elif status.status_type == 'Review' %} - reviewed {{ status.book.title }} - {% elif status.status_type == 'Comment' %} - commented on {{ status.book.title }} - {% elif status.status_type == 'Quotation' %} - quoted {{ status.book.title }} - {% elif status.reply_parent %} - {% with parent_status=status|parent %} - replied to {% include 'snippets/username.html' with user=parent_status.user possessive=True %} {% if parent_status.status_type == 'GeneratedNote' %}update{% else %}{{ parent_status.status_type | lower }}{% endif %} - {% endwith %} - {% endif %} -

    -
    +{% if status.status_type == 'GeneratedNote' %} + {{ status.content | safe }} +{% elif status.status_type == 'Review' and not status.name and not status.content%} + rated +{% elif status.status_type == 'Review' %} + reviewed +{% elif status.status_type == 'Comment' %} + commented on +{% elif status.status_type == 'Quotation' %} + quoted +{% elif status.reply_parent %} + {% with parent_status=status|parent %} + replied to {% include 'snippets/username.html' with user=parent_status.user possessive=True %} {% if parent_status.status_type == 'GeneratedNote' %}update{% else %}{{ parent_status.status_type | lower }}{% endif %} + {% endwith %} +{% endif %} +{% if status.book %} +{{ status.book.title }} +{% elif status.mention_books %} +{{ status.mention_books.first.title }} +{% endif %} diff --git a/bookwyrm/templates/snippets/tag.html b/bookwyrm/templates/snippets/tag.html index 38d2cd349..482cffc37 100644 --- a/bookwyrm/templates/snippets/tag.html +++ b/bookwyrm/templates/snippets/tag.html @@ -1,24 +1,22 @@
    -
    - - {{ tag.name }} - -
    - {% if tag.identifier in user_tags %} -
    - {% csrf_token %} - - - -
    +
    + {% csrf_token %} + + + +
    + + {{ tag.tag.name }} + + {% if tag.tag.identifier in user_tags %} + {% else %} - - {% csrf_token %} - - - - + {% endif %}
    -
    +
    diff --git a/bookwyrm/templates/snippets/trimmed_text.html b/bookwyrm/templates/snippets/trimmed_text.html new file mode 100644 index 000000000..9ed8fa87d --- /dev/null +++ b/bookwyrm/templates/snippets/trimmed_text.html @@ -0,0 +1,26 @@ +{% load fr_display %} +{% with 0|uuid as uuid %} +{% if full %} + +{% with full|text_overflow as trimmed %} +{% if trimmed != full %} +
    + + +
    +
    + + +
    +{% else %} +
    {{ full }}
    +{% endif %} +{% endwith %} + +{% endif %} +{% endwith %} + diff --git a/bookwyrm/templates/user_header.html b/bookwyrm/templates/snippets/user_header.html similarity index 67% rename from bookwyrm/templates/user_header.html rename to bookwyrm/templates/snippets/user_header.html index 6f499e39a..d9ca71b24 100644 --- a/bookwyrm/templates/user_header.html +++ b/bookwyrm/templates/snippets/user_header.html @@ -1,32 +1,22 @@ {% load humanize %} {% load fr_display %}
    -
    -

    User Profile

    - {% if is_self %} - - {% endif %} -
    -
    diff --git a/bookwyrm/templates/tag.html b/bookwyrm/templates/tag.html index 15b8ff283..c66413859 100644 --- a/bookwyrm/templates/tag.html +++ b/bookwyrm/templates/tag.html @@ -3,7 +3,7 @@ {% block content %}
    -

    Books tagged "{{ tag.name }}"

    +

    Books tagged "{{ tag.name }}"

    {% include 'snippets/book_tiles.html' with books=books.all %}
    diff --git a/bookwyrm/templates/user.html b/bookwyrm/templates/user.html index 3e409486c..00a1f2f78 100644 --- a/bookwyrm/templates/user.html +++ b/bookwyrm/templates/user.html @@ -1,10 +1,23 @@ {% extends 'layout.html' %} {% block content %} -
    - {% include 'user_header.html' with user=user %} +
    +
    +

    User profile

    +
    + {% if is_self %} + + {% endif %}
    +{% include 'snippets/user_header.html' with user=user %} +

    Shelves

    @@ -41,6 +54,26 @@

    No activities yet!

    {% endif %} + +
    {% endblock %} diff --git a/bookwyrm/templates/user_results.html b/bookwyrm/templates/user_results.html deleted file mode 100644 index 9ea169e28..000000000 --- a/bookwyrm/templates/user_results.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends 'layout.html' %} -{% block content %} -
    -

    User search results

    - {% if not results %} -

    No results found for "{{ query }}"

    - {% endif %} - {% for result in results %} -
    - {% include 'snippets/avatar.html' with user=result %} - {% include 'snippets/username.html' with user=result show_full=True %} - {% include 'snippets/follow_button.html' with user=result %} -
    - {% endfor %} -
    -{% endblock %} - diff --git a/bookwyrm/templates/user_shelves.html b/bookwyrm/templates/user_shelves.html deleted file mode 100644 index 239fd592d..000000000 --- a/bookwyrm/templates/user_shelves.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends 'layout.html' %} -{% load fr_display %} -{% block content %} -{% include 'user_header.html' with user=user %} - -{% for shelf in shelves %} -
    -

    {{ shelf.name }}

    - {% include 'snippets/shelf.html' with shelf=shelf ratings=ratings %} -
    -{% endfor %} - -{% endblock %} - diff --git a/bookwyrm/templatetags/fr_display.py b/bookwyrm/templatetags/fr_display.py index 818eae2af..690b4c642 100644 --- a/bookwyrm/templatetags/fr_display.py +++ b/bookwyrm/templatetags/fr_display.py @@ -1,5 +1,10 @@ ''' template filters ''' +from uuid import uuid4 +from datetime import datetime + +from dateutil.relativedelta import relativedelta from django import template +from django.utils import timezone from bookwyrm import models @@ -42,7 +47,8 @@ def get_replies(status): ''' get all direct replies to a status ''' #TODO: this limit could cause problems return models.Status.objects.filter( - reply_parent=status + reply_parent=status, + deleted=False, ).select_subclasses().all()[:10] @@ -105,42 +111,61 @@ def get_edition_info(book): return ', '.join(i for i in items if i) +@register.filter(name='book_description') +def get_book_description(book): + ''' use the work's text if the book doesn't have it ''' + return book.description or book.parent_work.description + +@register.filter(name='text_overflow') +def text_overflow(text): + ''' dont' let book descriptions run for ages ''' + if not text: + return '' + char_max = 400 + if text and len(text) < char_max: + return text + + trimmed = text[:char_max] + # go back to the last space + trimmed = ' '.join(trimmed.split(' ')[:-1]) + return trimmed + '...' + + +@register.filter(name='uuid') +def get_uuid(identifier): + ''' for avoiding clashing ids when there are many forms ''' + return '%s%s' % (identifier, uuid4()) + + +@register.filter(name="post_date") +def time_since(date): + ''' concise time ago function ''' + if not isinstance(date, datetime): + return '' + now = timezone.now() + delta = now - date + + if date < (now - relativedelta(weeks=1)): + return date.strftime('%b %-d') + delta = relativedelta(now, date) + if delta.days: + return '%dd' % delta.days + if delta.hours: + return '%dh' % delta.hours + if delta.minutes: + return '%dm' % delta.minutes + return '%ds' % delta.seconds + + @register.simple_tag(takes_context=True) -def shelve_button_identifier(context, book): +def active_shelf(context, book): ''' check what shelf a user has a book on, if any ''' #TODO: books can be on multiple shelves, handle that better shelf = models.ShelfBook.objects.filter( shelf__user=context['request'].user, book=book ).first() - if not shelf: - return 'to-read' - - identifier = shelf.shelf.identifier - if identifier == 'to-read': - return 'reading' - if identifier == 'reading': - return 'read' - return 'to-read' - - -@register.simple_tag(takes_context=True) -def shelve_button_text(context, book): - ''' check what shelf a user has a book on, if any ''' - #TODO: books can be on multiple shelves - shelf = models.ShelfBook.objects.filter( - shelf__user=context['request'].user, - book=book - ).first() - if not shelf: - return 'Want to read' - - identifier = shelf.shelf.identifier - if identifier == 'to-read': - return 'Start reading' - if identifier == 'reading': - return 'I\'m done!' - return 'Want to read' + return shelf.shelf if shelf else None @register.simple_tag(takes_context=False) @@ -148,4 +173,15 @@ def latest_read_through(book, user): ''' the most recent read activity ''' return models.ReadThrough.objects.filter( user=user, - book=book).order_by('-created_date').first() + book=book + ).order_by('-start_date').first() + + +@register.simple_tag(takes_context=False) +def active_read_through(book, user): + ''' the most recent read activity ''' + return models.ReadThrough.objects.filter( + user=user, + book=book, + finish_date__isnull=True + ).order_by('-start_date').first() diff --git a/bookwyrm/tests/activitypub/test_author.py b/bookwyrm/tests/activitypub/test_author.py index dd2e93afa..fd31f105e 100644 --- a/bookwyrm/tests/activitypub/test_author.py +++ b/bookwyrm/tests/activitypub/test_author.py @@ -12,8 +12,6 @@ class Author(TestCase): ) self.author = models.Author.objects.create( name='Author fullname', - first_name='Auth', - last_name='Or', aliases=['One', 'Two'], bio='bio bio bio', ) diff --git a/bookwyrm/tests/activitypub/test_base_activity.py b/bookwyrm/tests/activitypub/test_base_activity.py new file mode 100644 index 000000000..88997c447 --- /dev/null +++ b/bookwyrm/tests/activitypub/test_base_activity.py @@ -0,0 +1,215 @@ +''' tests the base functionality for activitypub dataclasses ''' +from io import BytesIO +import json +import pathlib +from unittest.mock import patch + +from dataclasses import dataclass +from django.test import TestCase +from PIL import Image +import responses + +from bookwyrm import activitypub +from bookwyrm.activitypub.base_activity import ActivityObject, \ + resolve_remote_id, set_related_field +from bookwyrm.activitypub import ActivitySerializerError +from bookwyrm import models + +class BaseActivity(TestCase): + ''' the super class for model-linked activitypub dataclasses ''' + def setUp(self): + ''' we're probably going to re-use this so why copy/paste ''' + self.user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.mouse', 'mouseword', local=True) + self.user.remote_id = 'http://example.com/a/b' + self.user.save() + + datafile = pathlib.Path(__file__).parent.joinpath( + '../data/ap_user.json' + ) + self.userdata = json.loads(datafile.read_bytes()) + # don't try to load the user icon + del self.userdata['icon'] + + image_file = pathlib.Path(__file__).parent.joinpath( + '../../static/images/default_avi.jpg') + image = Image.open(image_file) + output = BytesIO() + image.save(output, format=image.format) + self.image_data = output.getvalue() + + def test_init(self): + ''' simple successfuly init ''' + instance = ActivityObject(id='a', type='b') + self.assertTrue(hasattr(instance, 'id')) + self.assertTrue(hasattr(instance, 'type')) + + def test_init_missing(self): + ''' init with missing required params ''' + with self.assertRaises(ActivitySerializerError): + ActivityObject() + + def test_init_extra_fields(self): + ''' init ignoring additional fields ''' + instance = ActivityObject(id='a', type='b', fish='c') + self.assertTrue(hasattr(instance, 'id')) + self.assertTrue(hasattr(instance, 'type')) + + def test_init_default_field(self): + ''' replace an existing required field with a default field ''' + @dataclass(init=False) + class TestClass(ActivityObject): + ''' test class with default field ''' + type: str = 'TestObject' + + instance = TestClass(id='a') + self.assertEqual(instance.id, 'a') + self.assertEqual(instance.type, 'TestObject') + + def test_serialize(self): + ''' simple function for converting dataclass to dict ''' + instance = ActivityObject(id='a', type='b') + serialized = instance.serialize() + self.assertIsInstance(serialized, dict) + self.assertEqual(serialized['id'], 'a') + self.assertEqual(serialized['type'], 'b') + + @responses.activate + def test_resolve_remote_id(self): + ''' look up or load remote data ''' + # existing item + result = resolve_remote_id(models.User, 'http://example.com/a/b') + self.assertEqual(result, self.user) + + # remote item + responses.add( + responses.GET, + 'https://example.com/user/mouse', + json=self.userdata, + status=200) + + with patch('bookwyrm.models.user.set_remote_server.delay'): + result = resolve_remote_id( + models.User, 'https://example.com/user/mouse') + self.assertIsInstance(result, models.User) + self.assertEqual(result.remote_id, 'https://example.com/user/mouse') + self.assertEqual(result.name, 'MOUSE?? MOUSE!!') + + def test_to_model(self): + ''' the big boy of this module. it feels janky to test this with actual + models rather than a test model, but I don't know how to make a test + model so here we are. ''' + instance = ActivityObject(id='a', type='b') + with self.assertRaises(ActivitySerializerError): + instance.to_model(models.User) + + # test setting simple fields + self.assertEqual(self.user.name, '') + update_data = activitypub.Person(**self.user.to_activity()) + update_data.name = 'New Name' + update_data.to_model(models.User, self.user) + + self.assertEqual(self.user.name, 'New Name') + + def test_to_model_foreign_key(self): + ''' test setting one to one/foreign key ''' + update_data = activitypub.Person(**self.user.to_activity()) + update_data.publicKey['publicKeyPem'] = 'hi im secure' + update_data.to_model(models.User, self.user) + self.assertEqual(self.user.key_pair.public_key, 'hi im secure') + + @responses.activate + def test_to_model_image(self): + ''' update an image field ''' + update_data = activitypub.Person(**self.user.to_activity()) + update_data.icon = {'url': 'http://www.example.com/image.jpg'} + responses.add( + responses.GET, + 'http://www.example.com/image.jpg', + body=self.image_data, + status=200) + + self.assertIsNone(self.user.avatar.name) + with self.assertRaises(ValueError): + self.user.avatar.file #pylint: disable=pointless-statement + + update_data.to_model(models.User, self.user) + self.assertIsNotNone(self.user.avatar.name) + self.assertIsNotNone(self.user.avatar.file) + + def test_to_model_many_to_many(self): + ''' annoying that these all need special handling ''' + status = models.Status.objects.create( + content='test status', + user=self.user, + ) + book = models.Edition.objects.create( + title='Test Edition', remote_id='http://book.com/book') + update_data = activitypub.Note(**status.to_activity()) + update_data.tag = [ + { + 'type': 'Mention', + 'name': 'gerald', + 'href': 'http://example.com/a/b' + }, + { + 'type': 'Edition', + 'name': 'gerald j. books', + 'href': 'http://book.com/book' + }, + ] + update_data.to_model(models.Status, instance=status) + self.assertEqual(status.mention_users.first(), self.user) + self.assertEqual(status.mention_books.first(), book) + + + @responses.activate + def test_to_model_one_to_many(self): + ''' these are reversed relationships, where the secondary object + keys the primary object but not vice versa ''' + status = models.Status.objects.create( + content='test status', + user=self.user, + ) + update_data = activitypub.Note(**status.to_activity()) + update_data.attachment = [{ + 'url': 'http://www.example.com/image.jpg', + 'name': 'alt text', + 'type': 'Image', + }] + + responses.add( + responses.GET, + 'http://www.example.com/image.jpg', + body=self.image_data, + status=200) + + # sets the celery task call to the function call + with patch( + 'bookwyrm.activitypub.base_activity.set_related_field.delay'): + update_data.to_model(models.Status, instance=status) + self.assertIsNone(status.attachments.first()) + + + @responses.activate + def test_set_related_field(self): + ''' celery task to add back-references to created objects ''' + status = models.Status.objects.create( + content='test status', + user=self.user, + ) + data = { + 'url': 'http://www.example.com/image.jpg', + 'name': 'alt text', + 'type': 'Image', + } + responses.add( + responses.GET, + 'http://www.example.com/image.jpg', + body=self.image_data, + status=200) + set_related_field( + 'Image', 'Status', 'status', status.remote_id, data) + + self.assertIsInstance(status.attachments.first(), models.Image) + self.assertIsNotNone(status.attachments.first().image) diff --git a/bookwyrm/tests/activitypub/test_person.py b/bookwyrm/tests/activitypub/test_person.py index 8a077a29e..c7a8221c8 100644 --- a/bookwyrm/tests/activitypub/test_person.py +++ b/bookwyrm/tests/activitypub/test_person.py @@ -1,5 +1,7 @@ +# pylint: disable=missing-module-docstring, missing-class-docstring, missing-function-docstring import json import pathlib +from unittest.mock import patch from django.test import TestCase from bookwyrm import activitypub, models @@ -7,9 +9,6 @@ from bookwyrm import activitypub, models class Person(TestCase): def setUp(self): - self.user = models.User.objects.create_user( - 'rat', 'rat@rat.com', 'ratword', - ) datafile = pathlib.Path(__file__).parent.joinpath( '../data/ap_user.json' ) @@ -23,10 +22,10 @@ class Person(TestCase): self.assertEqual(activity.type, 'Person') - def test_serialize_model(self): - activity = self.user.to_activity() - self.assertEqual(activity['id'], self.user.remote_id) - self.assertEqual( - activity['endpoints'], - {'sharedInbox': self.user.shared_inbox} - ) + def test_user_to_model(self): + activity = activitypub.Person(**self.user_data) + with patch('bookwyrm.models.user.set_remote_server.delay'): + user = activity.to_model(models.User) + self.assertEqual(user.username, 'mouse@example.com') + self.assertEqual(user.remote_id, 'https://example.com/user/mouse') + self.assertFalse(user.local) diff --git a/bookwyrm/tests/activitypub/test_quotation.py b/bookwyrm/tests/activitypub/test_quotation.py index 50d0ac868..609208896 100644 --- a/bookwyrm/tests/activitypub/test_quotation.py +++ b/bookwyrm/tests/activitypub/test_quotation.py @@ -1,5 +1,7 @@ +''' quotation activty object serializer class ''' import json import pathlib +from unittest.mock import patch from django.test import TestCase from bookwyrm import activitypub, models @@ -8,13 +10,15 @@ from bookwyrm import activitypub, models class Quotation(TestCase): ''' we have hecka ways to create statuses ''' def setUp(self): - self.user = models.User.objects.create_user( - 'mouse', 'mouse@mouse.mouse', 'mouseword', - local=False, - inbox='https://example.com/user/mouse/inbox', - outbox='https://example.com/user/mouse/outbox', - remote_id='https://example.com/user/mouse', - ) + ''' model objects we'll need ''' + with patch('bookwyrm.models.user.set_remote_server.delay'): + self.user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.mouse', 'mouseword', + local=False, + inbox='https://example.com/user/mouse/inbox', + outbox='https://example.com/user/mouse/outbox', + remote_id='https://example.com/user/mouse', + ) self.book = models.Edition.objects.create( title='Example Edition', remote_id='https://example.com/book/1', @@ -26,6 +30,7 @@ class Quotation(TestCase): def test_quotation_activity(self): + ''' create a Quoteation ap object from json ''' quotation = activitypub.Quotation(**self.status_data) self.assertEqual(quotation.type, 'Quotation') @@ -39,6 +44,7 @@ class Quotation(TestCase): def test_activity_to_model(self): + ''' create a model instance from an activity object ''' activity = activitypub.Quotation(**self.status_data) quotation = activity.to_model(models.Quotation) diff --git a/bookwyrm/tests/connectors/test_abstract_connector.py b/bookwyrm/tests/connectors/test_abstract_connector.py index f4af8a1ac..f05645ab1 100644 --- a/bookwyrm/tests/connectors/test_abstract_connector.py +++ b/bookwyrm/tests/connectors/test_abstract_connector.py @@ -2,9 +2,8 @@ from django.test import TestCase from bookwyrm import models -from bookwyrm.connectors.abstract_connector import Mapping,\ - update_from_mappings -from bookwyrm.connectors.bookwyrm_connector import Connector +from bookwyrm.connectors.abstract_connector import Mapping +from bookwyrm.connectors.openlibrary import Connector class AbstractConnector(TestCase): @@ -13,7 +12,7 @@ class AbstractConnector(TestCase): models.Connector.objects.create( identifier='example.com', - connector_file='bookwyrm_connector', + connector_file='openlibrary', base_url='https://example.com', books_url='https:/example.com', covers_url='https://example.com', @@ -64,29 +63,6 @@ class AbstractConnector(TestCase): self.assertEqual(mapping.formatter('bb'), 'aabb') - def test_update_from_mappings(self): - data = { - 'title': 'Unused title', - 'isbn_10': '1234567890', - 'isbn_13': 'blahhh', - 'blah': 'bip', - 'format': 'hardcover', - 'series': ['one', 'two'], - } - mappings = [ - Mapping('isbn_10'), - Mapping('blah'),# not present on self.book - Mapping('physical_format', remote_field='format'), - Mapping('series', formatter=lambda x: x[0]), - ] - book = update_from_mappings(self.book, data, mappings) - self.assertEqual(book.title, 'Example Edition') - self.assertEqual(book.isbn_10, '1234567890') - self.assertEqual(book.isbn_13, None) - self.assertEqual(book.physical_format, 'hardcover') - self.assertEqual(book.series, 'one') - - def test_match_from_mappings(self): edition = models.Edition.objects.create( title='Blah', diff --git a/bookwyrm/tests/connectors/test_fedireads_connector.py b/bookwyrm/tests/connectors/test_bookwyrm_connector.py similarity index 61% rename from bookwyrm/tests/connectors/test_fedireads_connector.py rename to bookwyrm/tests/connectors/test_bookwyrm_connector.py index 6645a9369..8d866ca25 100644 --- a/bookwyrm/tests/connectors/test_fedireads_connector.py +++ b/bookwyrm/tests/connectors/test_bookwyrm_connector.py @@ -1,16 +1,17 @@ ''' testing book data connectors ''' -from dateutil import parser -from django.test import TestCase import json import pathlib +from django.test import TestCase from bookwyrm import models from bookwyrm.connectors.bookwyrm_connector import Connector -from bookwyrm.connectors.abstract_connector import SearchResult, get_date +from bookwyrm.connectors.abstract_connector import SearchResult class BookWyrmConnector(TestCase): + ''' this connector doesn't do much, just search ''' def setUp(self): + ''' create the connector ''' models.Connector.objects.create( identifier='example.com', connector_file='bookwyrm_connector', @@ -29,23 +30,9 @@ class BookWyrmConnector(TestCase): self.edition_data = json.loads(edition_file.read_bytes()) - def test_is_work_data(self): - self.assertEqual(self.connector.is_work_data(self.work_data), True) - self.assertEqual(self.connector.is_work_data(self.edition_data), False) - - - def test_get_edition_from_work_data(self): - edition = self.connector.get_edition_from_work_data(self.work_data) - self.assertEqual(edition['url'], 'https://example.com/book/122') - - - def test_get_work_from_edition_data(self): - work = self.connector.get_work_from_edition_date(self.edition_data) - self.assertEqual(work['url'], 'https://example.com/book/121') - - def test_format_search_result(self): - datafile = pathlib.Path(__file__).parent.joinpath('../data/fr_search.json') + datafile = pathlib.Path(__file__).parent.joinpath( + '../data/fr_search.json') search_data = json.loads(datafile.read_bytes()) results = self.connector.parse_search_data(search_data) self.assertIsInstance(results, list) @@ -56,9 +43,3 @@ class BookWyrmConnector(TestCase): self.assertEqual(result.key, 'https://example.com/book/122') self.assertEqual(result.author, 'Susanna Clarke') self.assertEqual(result.year, 2017) - - - def test_get_date(self): - date = get_date(self.edition_data['published_date']) - expected = parser.parse("2017-05-10T00:00:00+00:00") - self.assertEqual(date, expected) diff --git a/bookwyrm/tests/connectors/test_self_connector.py b/bookwyrm/tests/connectors/test_self_connector.py index 47c0b4543..4627bc8c8 100644 --- a/bookwyrm/tests/connectors/test_self_connector.py +++ b/bookwyrm/tests/connectors/test_self_connector.py @@ -1,6 +1,7 @@ ''' testing book data connectors ''' import datetime from django.test import TestCase +from django.utils import timezone from bookwyrm import models from bookwyrm.connectors.self_connector import Connector @@ -27,7 +28,7 @@ class SelfConnector(TestCase): self.edition = models.Edition.objects.create( title='Edition of Example Work', author_text='Anonymous', - published_date=datetime.datetime(1980, 5, 10), + published_date=datetime.datetime(1980, 5, 10, tzinfo=timezone.utc), parent_work=self.work, ) models.Edition.objects.create( @@ -48,7 +49,7 @@ class SelfConnector(TestCase): def test_format_search_result(self): - result = self.connector.format_search_result(self.edition) + result = self.connector.search('Edition of Example')[0] self.assertEqual(result.title, 'Edition of Example Work') self.assertEqual(result.key, self.edition.remote_id) self.assertEqual(result.author, 'Anonymous') @@ -57,15 +58,16 @@ class SelfConnector(TestCase): def test_search_rank(self): results = self.connector.search('Anonymous') - self.assertEqual(len(results), 3) - self.assertEqual(results[0].title, 'Edition of Example Work') - self.assertEqual(results[1].title, 'More Editions') - self.assertEqual(results[2].title, 'Another Edition') + self.assertEqual(len(results), 2) + self.assertEqual(results[0].title, 'More Editions') + self.assertEqual(results[1].title, 'Edition of Example Work') def test_search_default_filter(self): - self.edition.default = True - self.edition.save() + ''' it should get rid of duplicate editions for the same work ''' + self.work.default_edition = self.edition + self.work.save() + results = self.connector.search('Anonymous') self.assertEqual(len(results), 1) self.assertEqual(results[0].title, 'Edition of Example Work') diff --git a/bookwyrm/tests/data/ap_quotation.json b/bookwyrm/tests/data/ap_quotation.json index 5085547a6..36a4112be 100644 --- a/bookwyrm/tests/data/ap_quotation.json +++ b/bookwyrm/tests/data/ap_quotation.json @@ -13,14 +13,6 @@ "sensitive": false, "content": "commentary", "type": "Quotation", - "attachment": [ - { - "type": "Document", - "mediaType": "image//images/covers/2b4e4712-5a4d-4ac1-9df4-634cc9c7aff3jpg", - "url": "https://example.com/images/covers/2b4e4712-5a4d-4ac1-9df4-634cc9c7aff3jpg", - "name": "Cover of \"This Is How You Lose the Time War\"" - } - ], "replies": { "id": "https://example.com/user/mouse/quotation/13/replies", "type": "Collection", diff --git a/bookwyrm/tests/data/fr_edition.json b/bookwyrm/tests/data/fr_edition.json index bdb7c150a..0cc17d29a 100644 --- a/bookwyrm/tests/data/fr_edition.json +++ b/bookwyrm/tests/data/fr_edition.json @@ -1,42 +1,40 @@ { - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Document", - "book_type": "Edition", - "name": "Jonathan Strange and Mr Norrell", - "url": "https://example.com/book/122", + "id": "https://bookwyrm.social/book/5989", + "type": "Edition", "authors": [ - "https://example.com/author/25" + "https://bookwyrm.social/author/417" ], - "published_date": "2017-05-10T00:00:00+00:00", - "work": { - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Document", - "book_type": "Work", - "name": "Jonathan Strange and Mr Norrell", - "url": "https://example.com/book/121", - "authors": [ - "https://example.com/author/25" - ], - "title": "Jonathan Strange and Mr Norrell", - "attachment": [ - { - "type": "Document", - "mediaType": "image/jpg", - "url": "https://example.com/images/covers/8775540-M.jpg", - "name": "Cover of \"Jonathan Strange and Mr Norrell\"" - } - ] - }, - "title": "Jonathan Strange and Mr Norrell", - "subtitle": "Bloomsbury Modern Classics", - "isbn_13": "9781408891469", - "physical_format": "paperback", + "first_published_date": null, + "published_date": "2020-09-15T00:00:00+00:00", + "title": "Piranesi", + "sort_title": null, + "subtitle": null, + "description": "Piranesi's house is no ordinary building; its rooms are infinite, its corridors endless, its walls are lined with thousands upon thousands of statues, each one different from all the others. Within the labyrinth of halls an ocean is imprisoned; waves thunder up staircases, rooms are flooded in an instant. But Piranesi is not afraid; he understands the tides as he understands the pattern of the labyrinth itself. He lives to explore the house.\r\n\r\nThere is one other person in the house--a man called The Other, who visits Piranesi twice a week and asks for help with research into A Great and Secret Knowledge. But as Piranesi explores, evidence emerges of another person, and a terrible truth begins to unravel, revealing a world beyond the one Piranesi has always known.\r\n\r\nFor readers of Neil Gaiman's The Ocean at the End of the Lane and fans of Madeline Miller's Circe, Piranesi introduces an astonishing new world, an infinite labyrinth full of startling images of surreal beauty, haunted by the tides and the clouds.", + "languages": [ + "English" + ], + "series": null, + "series_number": null, + "subjects": [], + "subject_places": [], + "openlibrary_key": "OL29486417M", + "librarything_key": null, + "goodreads_key": null, "attachment": [ { - "type": "Document", - "mediaType": "image/jpg", - "url": "https://example.com/images/covers/9155821-M.jpg", - "name": "Cover of \"Jonathan Strange and Mr Norrell\"" + "url": "https://bookwyrm.social/images/covers/50202953._SX318_.jpg", + "type": "Image" } - ] + ], + "isbn_10": "1526622424", + "isbn_13": "9781526622426", + "oclc_number": null, + "asin": null, + "pages": 272, + "physical_format": null, + "publishers": [ + "Bloomsbury Publishing Plc" + ], + "work": "https://bookwyrm.social/book/5988", + "@context": "https://www.w3.org/ns/activitystreams" } diff --git a/bookwyrm/tests/data/fr_work.json b/bookwyrm/tests/data/fr_work.json index e93f6706e..3a36fc640 100644 --- a/bookwyrm/tests/data/fr_work.json +++ b/bookwyrm/tests/data/fr_work.json @@ -1,44 +1,34 @@ { - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Document", - "book_type": "Work", - "name": "Jonathan Strange and Mr Norrell", - "url": "https://example.com/book/121", + "id": "https://bookwyrm.social/book/5988", + "type": "Work", "authors": [ - "https://example.com/author/25" + "https://bookwyrm.social/author/417" ], - "editions": [ - { - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Document", - "book_type": "Edition", - "name": "Jonathan Strange and Mr Norrell", - "url": "https://example.com/book/122", - "authors": [ - "https://example.com/author/25" - ], - "published_date": "2017-05-10T00:00:00+00:00", - "title": "Jonathan Strange and Mr Norrell", - "subtitle": "Bloomsbury Modern Classics", - "isbn_13": "9781408891469", - "physical_format": "paperback", - "attachment": [ - { - "type": "Document", - "mediaType": "image/jpg", - "url": "https://example.com/images/covers/9155821-M.jpg", - "name": "Cover of \"Jonathan Strange and Mr Norrell\"" - } - ] - } + "first_published_date": null, + "published_date": null, + "title": "Piranesi", + "sort_title": null, + "subtitle": null, + "description": "**From the *New York Times* bestselling author of *Jonathan Strange & Mr. Norrell*, an intoxicating, hypnotic new novel set in a dreamlike alternative reality.**\r\n\r\nPiranesi's house is no ordinary building; its rooms are infinite, its corridors endless, its walls are lined with thousands upon thousands of statues, each one different from all the others. Within the labyrinth of halls an ocean is imprisoned; waves thunder up staircases, rooms are flooded in an instant. But Piranesi is not afraid; he understands the tides as he understands the pattern of the labyrinth itself. He lives to explore the house.\r\n\r\nThere is one other person in the house--a man called The Other, who visits Piranesi twice a week and asks for help with research into A Great and Secret Knowledge. But as Piranesi explores, evidence emerges of another person, and a terrible truth begins to unravel, revealing a world beyond the one Piranesi has always known.\r\n\r\nFor readers of Neil Gaiman's *The Ocean at the End of the Lane* and fans of Madeline Miller's *Circe*, *Piranesi* introduces an astonishing new world, an infinite labyrinth full of startling images of surreal beauty, haunted by the tides and the clouds.\r\n\r\nThis description comes from the publisher.", + "languages": [], + "series": null, + "series_number": null, + "subjects": [ + "English literature" ], - "title": "Jonathan Strange and Mr Norrell", + "subject_places": [], + "openlibrary_key": "OL20893680W", + "librarything_key": null, + "goodreads_key": null, "attachment": [ { - "type": "Document", - "mediaType": "image/jpg", - "url": "https://example.com/images/covers/8775540-M.jpg", - "name": "Cover of \"Jonathan Strange and Mr Norrell\"" + "url": "https://bookwyrm.social/images/covers/10226290-M.jpg", + "type": "Image" } - ] + ], + "lccn": null, + "editions": [ + "https://bookwyrm.social/book/5989" + ], + "@context": "https://www.w3.org/ns/activitystreams" } diff --git a/bookwyrm/tests/status/__init__.py b/bookwyrm/tests/incoming/__init__.py similarity index 100% rename from bookwyrm/tests/status/__init__.py rename to bookwyrm/tests/incoming/__init__.py diff --git a/bookwyrm/tests/test_incoming_favorite.py b/bookwyrm/tests/incoming/test_favorite.py similarity index 50% rename from bookwyrm/tests/test_incoming_favorite.py rename to bookwyrm/tests/incoming/test_favorite.py index 035021452..912657da5 100644 --- a/bookwyrm/tests/test_incoming_favorite.py +++ b/bookwyrm/tests/incoming/test_favorite.py @@ -1,23 +1,26 @@ import json import pathlib +from unittest.mock import patch from django.test import TestCase from bookwyrm import models, incoming class Favorite(TestCase): - ''' not too much going on in the books model but here we are ''' def setUp(self): - self.remote_user = models.User.objects.create_user( - 'rat', 'rat@rat.com', 'ratword', - local=False, - remote_id='https://example.com/users/rat', - inbox='https://example.com/users/rat/inbox', - outbox='https://example.com/users/rat/outbox', - ) + with patch('bookwyrm.models.user.set_remote_server.delay'): + with patch('bookwyrm.models.user.get_remote_reviews.delay'): + self.remote_user = models.User.objects.create_user( + 'rat', 'rat@rat.com', 'ratword', + local=False, + remote_id='https://example.com/users/rat', + inbox='https://example.com/users/rat/inbox', + outbox='https://example.com/users/rat/outbox', + ) self.local_user = models.User.objects.create_user( - 'mouse', 'mouse@mouse.com', 'mouseword', + 'mouse', 'mouse@mouse.com', 'mouseword', local=True, remote_id='http://local.com/user/mouse') + self.status = models.Status.objects.create( user=self.local_user, content='Test status', @@ -25,7 +28,7 @@ class Favorite(TestCase): ) datafile = pathlib.Path(__file__).parent.joinpath( - 'data/ap_user.json' + '../data/ap_user.json' ) self.user_data = json.loads(datafile.read_bytes()) @@ -34,24 +37,13 @@ class Favorite(TestCase): def test_handle_favorite(self): activity = { '@context': 'https://www.w3.org/ns/activitystreams', - 'id': 'http://example.com/activity/1', - - 'type': 'Create', + 'id': 'http://example.com/fav/1', 'actor': 'https://example.com/users/rat', 'published': 'Mon, 25 May 2020 19:31:20 GMT', - 'to': ['https://example.com/user/rat/followers'], - 'cc': ['https://www.w3.org/ns/activitystreams#Public'], - 'object': { - '@context': 'https://www.w3.org/ns/activitystreams', - 'id': 'http://example.com/fav/1', - 'type': 'Like', - 'actor': 'https://example.com/users/rat', - 'object': 'http://local.com/status/1', - }, - 'signature': {} + 'object': 'http://local.com/status/1', } - result = incoming.handle_favorite(activity) + incoming.handle_favorite(activity) fav = models.Favorite.objects.get(remote_id='http://example.com/fav/1') self.assertEqual(fav.status, self.status) diff --git a/bookwyrm/tests/incoming/test_follow.py b/bookwyrm/tests/incoming/test_follow.py new file mode 100644 index 000000000..799907dac --- /dev/null +++ b/bookwyrm/tests/incoming/test_follow.py @@ -0,0 +1,77 @@ +from unittest.mock import patch +from django.test import TestCase + +from bookwyrm import models, incoming + + +class IncomingFollow(TestCase): + def setUp(self): + with patch('bookwyrm.models.user.set_remote_server.delay'): + with patch('bookwyrm.models.user.get_remote_reviews.delay'): + self.remote_user = models.User.objects.create_user( + 'rat', 'rat@rat.com', 'ratword', + local=False, + remote_id='https://example.com/users/rat', + inbox='https://example.com/users/rat/inbox', + outbox='https://example.com/users/rat/outbox', + ) + self.local_user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.com', 'mouseword', local=True) + self.local_user.remote_id = 'http://local.com/user/mouse' + self.local_user.save() + + + def test_handle_follow(self): + activity = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/users/rat/follows/123", + "type": "Follow", + "actor": "https://example.com/users/rat", + "object": "http://local.com/user/mouse" + } + + with patch('bookwyrm.broadcast.broadcast_task.delay') as _: + incoming.handle_follow(activity) + + # notification created + notification = models.Notification.objects.get() + self.assertEqual(notification.user, self.local_user) + self.assertEqual(notification.notification_type, 'FOLLOW') + + # the request should have been deleted + requests = models.UserFollowRequest.objects.all() + self.assertEqual(list(requests), []) + + # the follow relationship should exist + follow = models.UserFollows.objects.get(user_object=self.local_user) + self.assertEqual(follow.user_subject, self.remote_user) + + + def test_handle_follow_manually_approved(self): + activity = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/users/rat/follows/123", + "type": "Follow", + "actor": "https://example.com/users/rat", + "object": "http://local.com/user/mouse" + } + + self.local_user.manually_approves_followers = True + self.local_user.save() + + with patch('bookwyrm.broadcast.broadcast_task.delay') as _: + incoming.handle_follow(activity) + + # notification created + notification = models.Notification.objects.get() + self.assertEqual(notification.user, self.local_user) + self.assertEqual(notification.notification_type, 'FOLLOW_REQUEST') + + # the request should exist + request = models.UserFollowRequest.objects.get() + self.assertEqual(request.user_subject, self.remote_user) + self.assertEqual(request.user_object, self.local_user) + + # the follow relationship should not exist + follow = models.UserFollows.objects.all() + self.assertEqual(list(follow), []) diff --git a/bookwyrm/tests/incoming/test_follow_accept.py b/bookwyrm/tests/incoming/test_follow_accept.py new file mode 100644 index 000000000..d6e048fb8 --- /dev/null +++ b/bookwyrm/tests/incoming/test_follow_accept.py @@ -0,0 +1,52 @@ +from unittest.mock import patch +from django.test import TestCase + +from bookwyrm import models, incoming + + +class IncomingFollowAccept(TestCase): + def setUp(self): + with patch('bookwyrm.models.user.set_remote_server.delay'): + with patch('bookwyrm.models.user.get_remote_reviews.delay'): + self.remote_user = models.User.objects.create_user( + 'rat', 'rat@rat.com', 'ratword', + local=False, + remote_id='https://example.com/users/rat', + inbox='https://example.com/users/rat/inbox', + outbox='https://example.com/users/rat/outbox', + ) + self.local_user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.com', 'mouseword', local=True) + self.local_user.remote_id = 'http://local.com/user/mouse' + self.local_user.save() + + + def test_handle_follow_accept(self): + activity = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/users/rat/follows/123#accepts", + "type": "Accept", + "actor": "https://example.com/users/rat", + "object": { + "id": "https://example.com/users/rat/follows/123", + "type": "Follow", + "actor": "http://local.com/user/mouse", + "object": "https://example.com/users/rat" + } + } + + models.UserFollowRequest.objects.create( + user_subject=self.local_user, + user_object=self.remote_user + ) + self.assertEqual(models.UserFollowRequest.objects.count(), 1) + + incoming.handle_follow_accept(activity) + + # request should be deleted + self.assertEqual(models.UserFollowRequest.objects.count(), 0) + + # relationship should be created + follows = self.remote_user.followers + self.assertEqual(follows.count(), 1) + self.assertEqual(follows.first(), self.local_user) diff --git a/bookwyrm/tests/models/test_base_model.py b/bookwyrm/tests/models/test_base_model.py index 14f8b7fb2..65cf892e7 100644 --- a/bookwyrm/tests/models/test_base_model.py +++ b/bookwyrm/tests/models/test_base_model.py @@ -1,25 +1,202 @@ ''' testing models ''' +from collections import namedtuple +from dataclasses import dataclass +import re from django.test import TestCase +from bookwyrm.activitypub.base_activity import ActivityObject from bookwyrm import models -from bookwyrm.models.base_model import BookWyrmModel +from bookwyrm.models import base_model +from bookwyrm.models.base_model import ActivitypubMixin from bookwyrm.settings import DOMAIN - class BaseModel(TestCase): + ''' functionality shared across models ''' def test_remote_id(self): - instance = BookWyrmModel() + ''' these should be generated ''' + instance = base_model.BookWyrmModel() instance.id = 1 expected = instance.get_remote_id() self.assertEqual(expected, 'https://%s/bookwyrmmodel/1' % DOMAIN) def test_remote_id_with_user(self): + ''' format of remote id when there's a user object ''' user = models.User.objects.create_user( - 'mouse', 'mouse@mouse.com', 'mouseword') - instance = BookWyrmModel() + 'mouse', 'mouse@mouse.com', 'mouseword', local=True) + instance = base_model.BookWyrmModel() instance.user = user instance.id = 1 expected = instance.get_remote_id() self.assertEqual( expected, 'https://%s/user/mouse/bookwyrmmodel/1' % DOMAIN) + + def test_execute_after_save(self): + ''' this function sets remote ids after creation ''' + # using Work because it BookWrymModel is abstract and this requires save + # Work is a relatively not-fancy model. + instance = models.Work.objects.create(title='work title') + instance.remote_id = None + base_model.execute_after_save(None, instance, True) + self.assertEqual( + instance.remote_id, + 'https://%s/book/%d' % (DOMAIN, instance.id) + ) + + # shouldn't set remote_id if it's not created + instance.remote_id = None + base_model.execute_after_save(None, instance, False) + self.assertIsNone(instance.remote_id) + + def test_to_create_activity(self): + ''' wrapper for ActivityPub "create" action ''' + user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.com', 'mouseword', local=True) + + object_activity = { + 'to': 'to field', 'cc': 'cc field', + 'content': 'hi', + 'published': '2020-12-04T17:52:22.623807+00:00', + } + MockSelf = namedtuple('Self', ('remote_id', 'to_activity')) + mock_self = MockSelf( + 'https://example.com/status/1', + lambda *args: object_activity + ) + activity = ActivitypubMixin.to_create_activity(mock_self, user) + self.assertEqual( + activity['id'], + 'https://example.com/status/1/activity' + ) + self.assertEqual(activity['actor'], user.remote_id) + self.assertEqual(activity['type'], 'Create') + self.assertEqual(activity['to'], 'to field') + self.assertEqual(activity['cc'], 'cc field') + self.assertEqual(activity['object'], object_activity) + self.assertEqual( + activity['signature'].creator, + '%s#main-key' % user.remote_id + ) + + def test_to_delete_activity(self): + ''' wrapper for Delete activity ''' + user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.com', 'mouseword', local=True) + + MockSelf = namedtuple('Self', ('remote_id', 'to_activity')) + mock_self = MockSelf( + 'https://example.com/status/1', + lambda *args: {} + ) + activity = ActivitypubMixin.to_delete_activity(mock_self, user) + self.assertEqual( + activity['id'], + 'https://example.com/status/1/activity' + ) + self.assertEqual(activity['actor'], user.remote_id) + self.assertEqual(activity['type'], 'Delete') + self.assertEqual( + activity['to'], + ['%s/followers' % user.remote_id]) + self.assertEqual( + activity['cc'], + ['https://www.w3.org/ns/activitystreams#Public']) + + def test_to_update_activity(self): + ''' ditto above but for Update ''' + user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.com', 'mouseword', local=True) + + MockSelf = namedtuple('Self', ('remote_id', 'to_activity')) + mock_self = MockSelf( + 'https://example.com/status/1', + lambda *args: {} + ) + activity = ActivitypubMixin.to_update_activity(mock_self, user) + self.assertIsNotNone( + re.match( + r'^https:\/\/example\.com\/status\/1#update\/.*', + activity['id'] + ) + ) + self.assertEqual(activity['actor'], user.remote_id) + self.assertEqual(activity['type'], 'Update') + self.assertEqual( + activity['to'], + ['https://www.w3.org/ns/activitystreams#Public']) + self.assertEqual(activity['object'], {}) + + def test_to_undo_activity(self): + ''' and again, for Undo ''' + user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.com', 'mouseword', local=True) + + MockSelf = namedtuple('Self', ('remote_id', 'to_activity')) + mock_self = MockSelf( + 'https://example.com/status/1', + lambda *args: {} + ) + activity = ActivitypubMixin.to_undo_activity(mock_self, user) + self.assertEqual( + activity['id'], + 'https://example.com/status/1#undo' + ) + self.assertEqual(activity['actor'], user.remote_id) + self.assertEqual(activity['type'], 'Undo') + self.assertEqual(activity['object'], {}) + + + def test_to_activity(self): + ''' model to ActivityPub json ''' + @dataclass(init=False) + class TestActivity(ActivityObject): + ''' real simple mock ''' + type: str = 'Test' + + class TestModel(ActivitypubMixin, base_model.BookWyrmModel): + ''' real simple mock model because BookWyrmModel is abstract ''' + + instance = TestModel() + instance.remote_id = 'https://www.example.com/test' + instance.activity_serializer = TestActivity + + activity = instance.to_activity() + self.assertIsInstance(activity, dict) + self.assertEqual(activity['id'], 'https://www.example.com/test') + self.assertEqual(activity['type'], 'Test') + + + def test_find_existing_by_remote_id(self): + ''' attempt to match a remote id to an object in the db ''' + # uses a different remote id scheme + # this isn't really part of this test directly but it's helpful to state + book = models.Edition.objects.create( + title='Test Edition', remote_id='http://book.com/book') + user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.mouse', 'mouseword', local=True) + user.remote_id = 'http://example.com/a/b' + user.save() + + self.assertEqual(book.origin_id, 'http://book.com/book') + self.assertNotEqual(book.remote_id, 'http://book.com/book') + + # uses subclasses + models.Comment.objects.create( + user=user, content='test status', book=book, \ + remote_id='https://comment.net') + + result = models.User.find_existing_by_remote_id('hi') + self.assertIsNone(result) + + result = models.User.find_existing_by_remote_id( + 'http://example.com/a/b') + self.assertEqual(result, user) + + # test using origin id + result = models.Edition.find_existing_by_remote_id( + 'http://book.com/book') + self.assertEqual(result, book) + + # test subclass match + result = models.Status.find_existing_by_remote_id( + 'https://comment.net') diff --git a/bookwyrm/tests/models/test_book_model.py b/bookwyrm/tests/models/test_book_model.py index 2cd980f87..125649928 100644 --- a/bookwyrm/tests/models/test_book_model.py +++ b/bookwyrm/tests/models/test_book_model.py @@ -2,6 +2,7 @@ from django.test import TestCase from bookwyrm import models, settings +from bookwyrm.models.book import isbn_10_to_13, isbn_13_to_10 class Book(TestCase): @@ -21,14 +22,9 @@ class Book(TestCase): ) def test_remote_id(self): - local_id = 'https://%s/book/%d' % (settings.DOMAIN, self.work.id) - self.assertEqual(self.work.get_remote_id(), local_id) - self.assertEqual(self.work.remote_id, 'https://example.com/book/1') - - def test_local_id(self): - ''' the local_id property for books ''' - expected_id = 'https://%s/book/%d' % (settings.DOMAIN, self.work.id) - self.assertEqual(self.work.local_id, expected_id) + remote_id = 'https://%s/book/%d' % (settings.DOMAIN, self.work.id) + self.assertEqual(self.work.get_remote_id(), remote_id) + self.assertEqual(self.work.remote_id, remote_id) def test_create_book(self): ''' you shouldn't be able to create Books (only editions and works) ''' @@ -38,21 +34,32 @@ class Book(TestCase): title='Invalid Book' ) - def test_default_edition(self): - ''' a work should always be able to produce a deafult edition ''' - self.assertIsInstance(self.work.default_edition, models.Edition) - self.assertEqual(self.work.default_edition, self.first_edition) + def test_isbn_10_to_13(self): + ''' checksums and so on ''' + isbn_10 = '178816167X' + isbn_13 = isbn_10_to_13(isbn_10) + self.assertEqual(isbn_13, '9781788161671') - self.second_edition.default = True - self.second_edition.save() + isbn_10 = '1-788-16167-X' + isbn_13 = isbn_10_to_13(isbn_10) + self.assertEqual(isbn_13, '9781788161671') - self.assertEqual(self.work.default_edition, self.second_edition) + + def test_isbn_13_to_10(self): + ''' checksums and so on ''' + isbn_13 = '9781788161671' + isbn_10 = isbn_13_to_10(isbn_13) + self.assertEqual(isbn_10, '178816167X') + + isbn_13 = '978-1788-16167-1' + isbn_10 = isbn_13_to_10(isbn_13) + self.assertEqual(isbn_10, '178816167X') class Shelf(TestCase): def setUp(self): user = models.User.objects.create_user( - 'mouse', 'mouse@mouse.mouse', 'mouseword') + 'mouse', 'mouse@mouse.mouse', 'mouseword', local=True) models.Shelf.objects.create( name='Test Shelf', identifier='test-shelf', user=user) diff --git a/bookwyrm/tests/models/test_fields.py b/bookwyrm/tests/models/test_fields.py new file mode 100644 index 000000000..a1e4ff71f --- /dev/null +++ b/bookwyrm/tests/models/test_fields.py @@ -0,0 +1,314 @@ +''' testing models ''' +from io import BytesIO +from collections import namedtuple +import json +import pathlib +import re +from unittest.mock import patch + +from PIL import Image +import responses + +from django.core.exceptions import ValidationError +from django.core.files.base import ContentFile +from django.db import models +from django.test import TestCase +from django.utils import timezone + +from bookwyrm.models import fields, User + +class ActivitypubFields(TestCase): + ''' overwrites standard model feilds to work with activitypub ''' + def test_validate_remote_id(self): + ''' should look like a url ''' + self.assertIsNone(fields.validate_remote_id( + 'http://www.example.com' + )) + self.assertIsNone(fields.validate_remote_id( + 'https://www.example.com' + )) + self.assertIsNone(fields.validate_remote_id( + 'http://example.com/dlfjg-23/x' + )) + self.assertRaises( + ValidationError, fields.validate_remote_id, + 'http:/example.com/dlfjg-23/x' + ) + self.assertRaises( + ValidationError, fields.validate_remote_id, + 'www.example.com/dlfjg-23/x' + ) + self.assertRaises( + ValidationError, fields.validate_remote_id, + 'http://www.example.com/dlfjg 23/x' + ) + + def test_activitypub_field_mixin(self): + ''' generic mixin with super basic to and from functionality ''' + instance = fields.ActivitypubFieldMixin() + self.assertEqual(instance.field_to_activity('fish'), 'fish') + self.assertEqual(instance.field_from_activity('fish'), 'fish') + self.assertFalse(instance.deduplication_field) + + instance = fields.ActivitypubFieldMixin( + activitypub_wrapper='endpoints', activitypub_field='outbox' + ) + self.assertEqual( + instance.field_to_activity('fish'), + {'outbox': 'fish'} + ) + self.assertEqual( + instance.field_from_activity({'outbox': 'fish'}), + 'fish' + ) + self.assertEqual(instance.get_activitypub_field(), 'endpoints') + + instance = fields.ActivitypubFieldMixin() + instance.name = 'snake_case_name' + self.assertEqual(instance.get_activitypub_field(), 'snakeCaseName') + + def test_remote_id_field(self): + ''' just sets some defaults on charfield ''' + instance = fields.RemoteIdField() + self.assertEqual(instance.max_length, 255) + self.assertTrue(instance.deduplication_field) + + with self.assertRaises(ValidationError): + instance.run_validators('http://www.example.com/dlfjg 23/x') + + def test_username_field(self): + ''' again, just setting defaults on username field ''' + instance = fields.UsernameField() + self.assertEqual(instance.activitypub_field, 'preferredUsername') + self.assertEqual(instance.max_length, 150) + self.assertEqual(instance.unique, True) + with self.assertRaises(ValidationError): + instance.run_validators('one two') + instance.run_validators('a*&') + instance.run_validators('trailingwhite ') + self.assertIsNone(instance.run_validators('aksdhf')) + + self.assertEqual(instance.field_to_activity('test@example.com'), 'test') + + def test_foreign_key(self): + ''' should be able to format a related model ''' + instance = fields.ForeignKey('User', on_delete=models.CASCADE) + Serializable = namedtuple('Serializable', ('to_activity', 'remote_id')) + item = Serializable(lambda: {'a': 'b'}, 'https://e.b/c') + # returns the remote_id field of the related object + self.assertEqual(instance.field_to_activity(item), 'https://e.b/c') + + @responses.activate + def test_foreign_key_from_activity_str(self): + ''' create a new object from a foreign key ''' + instance = fields.ForeignKey(User, on_delete=models.CASCADE) + datafile = pathlib.Path(__file__).parent.joinpath( + '../data/ap_user.json') + userdata = json.loads(datafile.read_bytes()) + # don't try to load the user icon + del userdata['icon'] + + # it shouldn't match with this unrelated user: + unrelated_user = User.objects.create_user( + 'rat', 'rat@rat.rat', 'ratword', local=True) + + # test receiving an unknown remote id and loading data + responses.add( + responses.GET, + 'https://example.com/user/mouse', + json=userdata, + status=200) + with patch('bookwyrm.models.user.set_remote_server.delay'): + value = instance.field_from_activity( + 'https://example.com/user/mouse') + self.assertIsInstance(value, User) + self.assertNotEqual(value, unrelated_user) + self.assertEqual(value.remote_id, 'https://example.com/user/mouse') + self.assertEqual(value.name, 'MOUSE?? MOUSE!!') + + + def test_foreign_key_from_activity_dict(self): + ''' test recieving activity json ''' + instance = fields.ForeignKey(User, on_delete=models.CASCADE) + datafile = pathlib.Path(__file__).parent.joinpath( + '../data/ap_user.json') + userdata = json.loads(datafile.read_bytes()) + # don't try to load the user icon + del userdata['icon'] + + # it shouldn't match with this unrelated user: + unrelated_user = User.objects.create_user( + 'rat', 'rat@rat.rat', 'ratword', local=True) + with patch('bookwyrm.models.user.set_remote_server.delay'): + value = instance.field_from_activity(userdata) + self.assertIsInstance(value, User) + self.assertNotEqual(value, unrelated_user) + self.assertEqual(value.remote_id, 'https://example.com/user/mouse') + self.assertEqual(value.name, 'MOUSE?? MOUSE!!') + # et cetera but we're not testing serializing user json + + + def test_foreign_key_from_activity_dict_existing(self): + ''' test receiving a dict of an existing object in the db ''' + instance = fields.ForeignKey(User, on_delete=models.CASCADE) + datafile = pathlib.Path(__file__).parent.joinpath( + '../data/ap_user.json' + ) + userdata = json.loads(datafile.read_bytes()) + user = User.objects.create_user( + 'mouse', 'mouse@mouse.mouse', 'mouseword', local=True) + user.remote_id = 'https://example.com/user/mouse' + user.save() + User.objects.create_user( + 'rat', 'rat@rat.rat', 'ratword', local=True) + + value = instance.field_from_activity(userdata) + self.assertEqual(value, user) + + + def test_foreign_key_from_activity_str_existing(self): + ''' test receiving a remote id of an existing object in the db ''' + instance = fields.ForeignKey(User, on_delete=models.CASCADE) + user = User.objects.create_user( + 'mouse', 'mouse@mouse.mouse', 'mouseword', local=True) + User.objects.create_user( + 'rat', 'rat@rat.rat', 'ratword', local=True) + + value = instance.field_from_activity(user.remote_id) + self.assertEqual(value, user) + + + def test_one_to_one_field(self): + ''' a gussied up foreign key ''' + instance = fields.OneToOneField('User', on_delete=models.CASCADE) + Serializable = namedtuple('Serializable', ('to_activity', 'remote_id')) + item = Serializable(lambda: {'a': 'b'}, 'https://e.b/c') + self.assertEqual(instance.field_to_activity(item), {'a': 'b'}) + + def test_many_to_many_field(self): + ''' lists! ''' + instance = fields.ManyToManyField('User') + + Serializable = namedtuple('Serializable', ('to_activity', 'remote_id')) + Queryset = namedtuple('Queryset', ('all', 'instance')) + item = Serializable(lambda: {'a': 'b'}, 'https://e.b/c') + another_item = Serializable(lambda: {}, 'example.com') + + items = Queryset(lambda: [item], another_item) + + self.assertEqual(instance.field_to_activity(items), ['https://e.b/c']) + + instance = fields.ManyToManyField('User', link_only=True) + instance.name = 'snake_case' + self.assertEqual( + instance.field_to_activity(items), + 'example.com/snake_case' + ) + + @responses.activate + def test_many_to_many_field_from_activity(self): + ''' resolve related fields for a list, takes a list of remote ids ''' + instance = fields.ManyToManyField(User) + datafile = pathlib.Path(__file__).parent.joinpath( + '../data/ap_user.json' + ) + userdata = json.loads(datafile.read_bytes()) + # don't try to load the user icon + del userdata['icon'] + + # test receiving an unknown remote id and loading data + responses.add( + responses.GET, + 'https://example.com/user/mouse', + json=userdata, + status=200) + with patch('bookwyrm.models.user.set_remote_server.delay'): + value = instance.field_from_activity( + ['https://example.com/user/mouse', 'bleh'] + ) + self.assertIsInstance(value, list) + self.assertEqual(len(value), 1) + self.assertIsInstance(value[0], User) + + def test_tag_field(self): + ''' a special type of many to many field ''' + instance = fields.TagField('User') + + Serializable = namedtuple( + 'Serializable', + ('to_activity', 'remote_id', 'name_field', 'name') + ) + Queryset = namedtuple('Queryset', ('all', 'instance')) + item = Serializable( + lambda: {'a': 'b'}, 'https://e.b/c', 'name', 'Name') + another_item = Serializable( + lambda: {}, 'example.com', '', '') + items = Queryset(lambda: [item], another_item) + + result = instance.field_to_activity(items) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].href, 'https://e.b/c') + self.assertEqual(result[0].name, 'Name') + self.assertEqual(result[0].type, 'Serializable') + + + def test_tag_field_from_activity(self): + ''' loadin' a list of items from Links ''' + # TODO + + + @responses.activate + def test_image_field(self): + ''' storing images ''' + user = User.objects.create_user( + 'mouse', 'mouse@mouse.mouse', 'mouseword', local=True) + image_file = pathlib.Path(__file__).parent.joinpath( + '../../static/images/default_avi.jpg') + image = Image.open(image_file) + output = BytesIO() + image.save(output, format=image.format) + user.avatar.save( + 'test.jpg', + ContentFile(output.getvalue()) + ) + + output = fields.image_serializer(user.avatar) + self.assertIsNotNone( + re.match( + r'.*\.jpg', + output.url, + ) + ) + self.assertEqual(output.type, 'Image') + + instance = fields.ImageField() + + self.assertEqual(instance.field_to_activity(user.avatar), output) + + responses.add( + responses.GET, + 'http://www.example.com/image.jpg', + body=user.avatar.file.read(), + status=200) + loaded_image = instance.field_from_activity( + 'http://www.example.com/image.jpg') + self.assertIsInstance(loaded_image, list) + self.assertIsInstance(loaded_image[1], ContentFile) + + + def test_datetime_field(self): + ''' this one is pretty simple, it just has to use isoformat ''' + instance = fields.DateTimeField() + now = timezone.now() + self.assertEqual(instance.field_to_activity(now), now.isoformat()) + self.assertEqual( + instance.field_from_activity(now.isoformat()), now + ) + self.assertEqual(instance.field_from_activity('bip'), None) + + + def test_array_field(self): + ''' idk why it makes them strings but probably for a good reason ''' + instance = fields.ArrayField(fields.IntegerField) + self.assertEqual(instance.field_to_activity([0, 1]), ['0', '1']) diff --git a/bookwyrm/tests/models/test_import_model.py b/bookwyrm/tests/models/test_import_model.py index 5e488199b..c703d08a4 100644 --- a/bookwyrm/tests/models/test_import_model.py +++ b/bookwyrm/tests/models/test_import_model.py @@ -1,5 +1,6 @@ ''' testing models ''' import datetime +from django.utils import timezone from django.test import TestCase from bookwyrm import models @@ -24,7 +25,7 @@ class ImportJob(TestCase): 'Number of Pages': 416, 'Year Published': 2019, 'Original Publication Year': 2019, - 'Date Read': '2019/04/09', + 'Date Read': '2019/04/12', 'Date Added': '2019/04/09', 'Bookshelves': '', 'Bookshelves with positions': '', @@ -51,7 +52,7 @@ class ImportJob(TestCase): unknown_read_data['Date Read'] = '' user = models.User.objects.create_user( - 'mouse', 'mouse@mouse.mouse', 'mouseword') + 'mouse', 'mouse@mouse.mouse', 'mouseword', local=True) job = models.ImportJob.objects.create(user=user) models.ImportItem.objects.create( job=job, index=1, data=currently_reading_data) @@ -77,31 +78,29 @@ class ImportJob(TestCase): def test_date_added(self): ''' converts to the local shelf typology ''' - expected = datetime.datetime(2019, 4, 9, 0, 0) + expected = datetime.datetime(2019, 4, 9, 0, 0, tzinfo=timezone.utc) item = models.ImportItem.objects.get(index=1) self.assertEqual(item.date_added, expected) def test_date_read(self): ''' converts to the local shelf typology ''' - expected = datetime.datetime(2019, 4, 9, 0, 0) + expected = datetime.datetime(2019, 4, 12, 0, 0, tzinfo=timezone.utc) item = models.ImportItem.objects.get(index=2) self.assertEqual(item.date_read, expected) def test_currently_reading_reads(self): expected = [models.ReadThrough( - start_date=datetime.datetime(2019, 4, 9, 0, 0))] + start_date=datetime.datetime(2019, 4, 9, 0, 0, tzinfo=timezone.utc))] actual = models.ImportItem.objects.get(index=1) self.assertEqual(actual.reads[0].start_date, expected[0].start_date) self.assertEqual(actual.reads[0].finish_date, expected[0].finish_date) def test_read_reads(self): - expected = [models.ReadThrough( - finish_date=datetime.datetime(2019, 4, 9, 0, 0))] actual = models.ImportItem.objects.get(index=2) - self.assertEqual(actual.reads[0].start_date, expected[0].start_date) - self.assertEqual(actual.reads[0].finish_date, expected[0].finish_date) + self.assertEqual(actual.reads[0].start_date, datetime.datetime(2019, 4, 9, 0, 0, tzinfo=timezone.utc)) + self.assertEqual(actual.reads[0].finish_date, datetime.datetime(2019, 4, 12, 0, 0, tzinfo=timezone.utc)) def test_unread_reads(self): expected = [] diff --git a/bookwyrm/tests/models/test_relationship_models.py b/bookwyrm/tests/models/test_relationship_models.py new file mode 100644 index 000000000..c5c619a02 --- /dev/null +++ b/bookwyrm/tests/models/test_relationship_models.py @@ -0,0 +1,122 @@ +''' testing models ''' +from unittest.mock import patch +from django.test import TestCase + +from bookwyrm import models + + +class Relationship(TestCase): + def setUp(self): + with patch('bookwyrm.models.user.set_remote_server.delay'): + self.remote_user = models.User.objects.create_user( + 'rat', 'rat@rat.com', 'ratword', + local=False, + remote_id='https://example.com/users/rat', + inbox='https://example.com/users/rat/inbox', + outbox='https://example.com/users/rat/outbox', + ) + self.local_user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.com', 'mouseword', local=True) + self.local_user.remote_id = 'http://local.com/user/mouse' + self.local_user.save() + + def test_user_follows(self): + rel = models.UserFollows.objects.create( + user_subject=self.local_user, + user_object=self.remote_user + ) + + self.assertEqual( + rel.remote_id, + 'http://local.com/user/mouse#follows/%d' % rel.id + ) + + activity = rel.to_activity() + self.assertEqual(activity['id'], rel.remote_id) + self.assertEqual(activity['actor'], self.local_user.remote_id) + self.assertEqual(activity['object'], self.remote_user.remote_id) + + def test_user_follow_accept_serialization(self): + rel = models.UserFollows.objects.create( + user_subject=self.local_user, + user_object=self.remote_user + ) + + self.assertEqual( + rel.remote_id, + 'http://local.com/user/mouse#follows/%d' % rel.id + ) + accept = rel.to_accept_activity() + self.assertEqual(accept['type'], 'Accept') + self.assertEqual( + accept['id'], + 'http://local.com/user/mouse#accepts/%d' % rel.id + ) + self.assertEqual(accept['actor'], self.remote_user.remote_id) + self.assertEqual(accept['object']['id'], rel.remote_id) + self.assertEqual(accept['object']['actor'], self.local_user.remote_id) + self.assertEqual(accept['object']['object'], self.remote_user.remote_id) + + def test_user_follow_reject_serialization(self): + rel = models.UserFollows.objects.create( + user_subject=self.local_user, + user_object=self.remote_user + ) + + self.assertEqual( + rel.remote_id, + 'http://local.com/user/mouse#follows/%d' % rel.id + ) + reject = rel.to_reject_activity() + self.assertEqual(reject['type'], 'Reject') + self.assertEqual( + reject['id'], + 'http://local.com/user/mouse#rejects/%d' % rel.id + ) + self.assertEqual(reject['actor'], self.remote_user.remote_id) + self.assertEqual(reject['object']['id'], rel.remote_id) + self.assertEqual(reject['object']['actor'], self.local_user.remote_id) + self.assertEqual(reject['object']['object'], self.remote_user.remote_id) + + + def test_user_follows_from_request(self): + request = models.UserFollowRequest.objects.create( + user_subject=self.local_user, + user_object=self.remote_user + ) + self.assertEqual( + request.remote_id, + 'http://local.com/user/mouse#follows/%d' % request.id + ) + self.assertEqual(request.status, 'follow_request') + + rel = models.UserFollows.from_request(request) + self.assertEqual( + rel.remote_id, + 'http://local.com/user/mouse#follows/%d' % request.id + ) + self.assertEqual(rel.status, 'follows') + self.assertEqual(rel.user_subject, self.local_user) + self.assertEqual(rel.user_object, self.remote_user) + + + def test_user_follows_from_request_custom_remote_id(self): + request = models.UserFollowRequest.objects.create( + user_subject=self.local_user, + user_object=self.remote_user, + remote_id='http://antoher.server/sdkfhskdjf/23' + ) + self.assertEqual( + request.remote_id, + 'http://antoher.server/sdkfhskdjf/23' + ) + self.assertEqual(request.status, 'follow_request') + + rel = models.UserFollows.from_request(request) + self.assertEqual( + rel.remote_id, + 'http://antoher.server/sdkfhskdjf/23' + ) + self.assertEqual(rel.status, 'follows') + self.assertEqual(rel.user_subject, self.local_user) + self.assertEqual(rel.user_object, self.remote_user) diff --git a/bookwyrm/tests/models/test_status_model.py b/bookwyrm/tests/models/test_status_model.py index 719adfe59..7c625197f 100644 --- a/bookwyrm/tests/models/test_status_model.py +++ b/bookwyrm/tests/models/test_status_model.py @@ -7,7 +7,7 @@ from bookwyrm import models, settings class Status(TestCase): def setUp(self): user = models.User.objects.create_user( - 'mouse', 'mouse@mouse.mouse', 'mouseword') + 'mouse', 'mouse@mouse.mouse', 'mouseword', local=True) book = models.Edition.objects.create(title='Example Edition') models.Status.objects.create(user=user, content='Blah blah') @@ -40,13 +40,3 @@ class Status(TestCase): expected_id = 'https://%s/user/mouse/review/%d' % \ (settings.DOMAIN, review.id) self.assertEqual(review.remote_id, expected_id) - - -class Tag(TestCase): - def test_tag(self): - book = models.Edition.objects.create(title='Example Edition') - user = models.User.objects.create_user( - 'mouse', 'mouse@mouse.mouse', 'mouseword') - tag = models.Tag.objects.create(user=user, book=book, name='t/est tag') - self.assertEqual(tag.identifier, 't%2Fest+tag') - diff --git a/bookwyrm/tests/models/test_user_model.py b/bookwyrm/tests/models/test_user_model.py index a423de37c..0454fb400 100644 --- a/bookwyrm/tests/models/test_user_model.py +++ b/bookwyrm/tests/models/test_user_model.py @@ -1,4 +1,5 @@ ''' testing models ''' +from unittest.mock import patch from django.test import TestCase from bookwyrm import models @@ -7,28 +8,65 @@ from bookwyrm.settings import DOMAIN class User(TestCase): def setUp(self): - models.User.objects.create_user( - 'mouse', 'mouse@mouse.mouse', 'mouseword') + self.user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.mouse', 'mouseword', local=True) def test_computed_fields(self): ''' username instead of id here ''' - user = models.User.objects.get(localname='mouse') expected_id = 'https://%s/user/mouse' % DOMAIN - self.assertEqual(user.remote_id, expected_id) - self.assertEqual(user.username, 'mouse@%s' % DOMAIN) - self.assertEqual(user.localname, 'mouse') - self.assertEqual(user.shared_inbox, 'https://%s/inbox' % DOMAIN) - self.assertEqual(user.inbox, '%s/inbox' % expected_id) - self.assertEqual(user.outbox, '%s/outbox' % expected_id) - self.assertIsNotNone(user.private_key) - self.assertIsNotNone(user.public_key) + self.assertEqual(self.user.remote_id, expected_id) + self.assertEqual(self.user.username, 'mouse@%s' % DOMAIN) + self.assertEqual(self.user.localname, 'mouse') + self.assertEqual(self.user.shared_inbox, 'https://%s/inbox' % DOMAIN) + self.assertEqual(self.user.inbox, '%s/inbox' % expected_id) + self.assertEqual(self.user.outbox, '%s/outbox' % expected_id) + self.assertIsNotNone(self.user.key_pair.private_key) + self.assertIsNotNone(self.user.key_pair.public_key) + + def test_remote_user(self): + with patch('bookwyrm.models.user.set_remote_server.delay'): + user = models.User.objects.create_user( + 'rat', 'rat@rat.rat', 'ratword', local=False, + remote_id='https://example.com/dfjkg') + self.assertEqual(user.username, 'rat@example.com') def test_user_shelves(self): - user = models.User.objects.get(localname='mouse') - shelves = models.Shelf.objects.filter(user=user).all() + shelves = models.Shelf.objects.filter(user=self.user).all() self.assertEqual(len(shelves), 3) names = [s.name for s in shelves] - self.assertEqual(names, ['To Read', 'Currently Reading', 'Read']) + self.assertTrue('To Read' in names) + self.assertTrue('Currently Reading' in names) + self.assertTrue('Read' in names) ids = [s.identifier for s in shelves] - self.assertEqual(ids, ['to-read', 'reading', 'read']) + self.assertTrue('to-read' in ids) + self.assertTrue('reading' in ids) + self.assertTrue('read' in ids) + + + def test_activitypub_serialize(self): + activity = self.user.to_activity() + self.assertEqual(activity['id'], self.user.remote_id) + self.assertEqual(activity['@context'], [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + { + 'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers', + 'schema': 'http://schema.org#', + 'PropertyValue': 'schema:PropertyValue', + 'value': 'schema:value', + } + ]) + self.assertEqual(activity['preferredUsername'], self.user.localname) + self.assertEqual(activity['name'], self.user.name) + self.assertEqual(activity['inbox'], self.user.inbox) + self.assertEqual(activity['outbox'], self.user.outbox) + self.assertEqual(activity['bookwyrmUser'], True) + self.assertEqual(activity['discoverable'], True) + self.assertEqual(activity['type'], 'Person') + + def test_activitypub_outbox(self): + activity = self.user.to_outbox() + self.assertEqual(activity['type'], 'OrderedCollection') + self.assertEqual(activity['id'], self.user.outbox) + self.assertEqual(activity['totalItems'], 0) diff --git a/bookwyrm/tests/outgoing/__init__.py b/bookwyrm/tests/outgoing/__init__.py new file mode 100644 index 000000000..b6e690fd5 --- /dev/null +++ b/bookwyrm/tests/outgoing/__init__.py @@ -0,0 +1 @@ +from . import * diff --git a/bookwyrm/tests/outgoing/test_follow.py b/bookwyrm/tests/outgoing/test_follow.py new file mode 100644 index 000000000..d27db8761 --- /dev/null +++ b/bookwyrm/tests/outgoing/test_follow.py @@ -0,0 +1,80 @@ +from unittest.mock import patch +from django.test import TestCase + +from bookwyrm import models, outgoing +from bookwyrm.settings import DOMAIN + + +class Following(TestCase): + def setUp(self): + with patch('bookwyrm.models.user.set_remote_server'): + self.remote_user = models.User.objects.create_user( + 'rat', 'rat@rat.com', 'ratword', + local=False, + remote_id='https://example.com/users/rat', + inbox='https://example.com/users/rat/inbox', + outbox='https://example.com/users/rat/outbox', + ) + self.local_user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.com', 'mouseword', + local=True, + remote_id='http://local.com/users/mouse', + ) + + + def test_handle_follow(self): + self.assertEqual(models.UserFollowRequest.objects.count(), 0) + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + outgoing.handle_follow(self.local_user, self.remote_user) + + rel = models.UserFollowRequest.objects.get() + + self.assertEqual(rel.user_subject, self.local_user) + self.assertEqual(rel.user_object, self.remote_user) + self.assertEqual(rel.status, 'follow_request') + + + def test_handle_unfollow(self): + self.remote_user.followers.add(self.local_user) + self.assertEqual(self.remote_user.followers.count(), 1) + with patch('bookwyrm.broadcast.broadcast_task.delay'): + outgoing.handle_unfollow(self.local_user, self.remote_user) + + self.assertEqual(self.remote_user.followers.count(), 0) + + + def test_handle_accept(self): + rel = models.UserFollowRequest.objects.create( + user_subject=self.local_user, + user_object=self.remote_user + ) + rel_id = rel.id + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + outgoing.handle_accept(rel) + # request should be deleted + self.assertEqual( + models.UserFollowRequest.objects.filter(id=rel_id).count(), 0 + ) + # follow relationship should exist + self.assertEqual(self.remote_user.followers.first(), self.local_user) + + + def test_handle_reject(self): + rel = models.UserFollowRequest.objects.create( + user_subject=self.local_user, + user_object=self.remote_user + ) + rel_id = rel.id + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + outgoing.handle_reject(rel) + # request should be deleted + self.assertEqual( + models.UserFollowRequest.objects.filter(id=rel_id).count(), 0 + ) + # follow relationship should not exist + self.assertEqual( + models.UserFollows.objects.filter(id=rel_id).count(), 0 + ) diff --git a/bookwyrm/tests/outgoing/test_remote_webfinger.py b/bookwyrm/tests/outgoing/test_remote_webfinger.py new file mode 100644 index 000000000..1bf884a6e --- /dev/null +++ b/bookwyrm/tests/outgoing/test_remote_webfinger.py @@ -0,0 +1,61 @@ +''' testing user lookup ''' +import json +import pathlib +from unittest.mock import patch + +from django.test import TestCase +import responses + +from bookwyrm import models, outgoing +from bookwyrm.settings import DOMAIN + +class TestOutgoingRemoteWebfinger(TestCase): + ''' overwrites standard model feilds to work with activitypub ''' + def setUp(self): + ''' get user data ready ''' + datafile = pathlib.Path(__file__).parent.joinpath( + '../data/ap_user.json' + ) + self.userdata = json.loads(datafile.read_bytes()) + del self.userdata['icon'] + + def test_existing_user(self): + ''' simple database lookup by username ''' + user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.mouse', 'mouseword', local=True) + + result = outgoing.handle_remote_webfinger('@mouse@%s' % DOMAIN) + self.assertEqual(result, user) + + result = outgoing.handle_remote_webfinger('mouse@%s' % DOMAIN) + self.assertEqual(result, user) + + + @responses.activate + def test_load_user(self): + username = 'mouse@example.com' + wellknown = { + "subject": "acct:mouse@example.com", + "links": [ + { + "rel": "self", + "type": "application/activity+json", + "href": "https://example.com/user/mouse" + } + ] + } + responses.add( + responses.GET, + 'https://example.com/.well-known/webfinger?resource=acct:%s' \ + % username, + json=wellknown, + status=200) + responses.add( + responses.GET, + 'https://example.com/user/mouse', + json=self.userdata, + status=200) + with patch('bookwyrm.models.user.set_remote_server.delay'): + result = outgoing.handle_remote_webfinger('@mouse@example.com') + self.assertIsInstance(result, models.User) + self.assertEqual(result.username, 'mouse@example.com') diff --git a/bookwyrm/tests/outgoing/test_shelving.py b/bookwyrm/tests/outgoing/test_shelving.py new file mode 100644 index 000000000..5567784e5 --- /dev/null +++ b/bookwyrm/tests/outgoing/test_shelving.py @@ -0,0 +1,69 @@ +from unittest.mock import patch +from django.test import TestCase + +from bookwyrm import models, outgoing + + +class Shelving(TestCase): + def setUp(self): + self.user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.com', 'mouseword', + local=True, + remote_id='http://local.com/users/mouse', + ) + work = models.Work.objects.create( + title='Example work', + ) + self.book = models.Edition.objects.create( + title='Example Edition', + remote_id='https://example.com/book/1', + parent_work=work, + ) + self.shelf = models.Shelf.objects.create( + name='Test Shelf', + identifier='test-shelf', + user=self.user + ) + + + def test_handle_shelve(self): + with patch('bookwyrm.broadcast.broadcast_task.delay') as _: + outgoing.handle_shelve(self.user, self.book, self.shelf) + # make sure the book is on the shelf + self.assertEqual(self.shelf.books.get(), self.book) + + + def test_handle_shelve_to_read(self): + shelf = models.Shelf.objects.get(identifier='to-read') + + with patch('bookwyrm.broadcast.broadcast_task.delay') as _: + outgoing.handle_shelve(self.user, self.book, shelf) + # make sure the book is on the shelf + self.assertEqual(shelf.books.get(), self.book) + + + def test_handle_shelve_reading(self): + shelf = models.Shelf.objects.get(identifier='reading') + + with patch('bookwyrm.broadcast.broadcast_task.delay') as _: + outgoing.handle_shelve(self.user, self.book, shelf) + # make sure the book is on the shelf + self.assertEqual(shelf.books.get(), self.book) + + + def test_handle_shelve_read(self): + shelf = models.Shelf.objects.get(identifier='read') + + with patch('bookwyrm.broadcast.broadcast_task.delay') as _: + outgoing.handle_shelve(self.user, self.book, shelf) + # make sure the book is on the shelf + self.assertEqual(shelf.books.get(), self.book) + + + def test_handle_unshelve(self): + self.shelf.books.add(self.book) + self.shelf.save() + self.assertEqual(self.shelf.books.count(), 1) + with patch('bookwyrm.broadcast.broadcast_task.delay') as _: + outgoing.handle_unshelve(self.user, self.book, self.shelf) + self.assertEqual(self.shelf.books.count(), 0) diff --git a/bookwyrm/tests/status/test_comment.py b/bookwyrm/tests/status/test_comment.py deleted file mode 100644 index be127d887..000000000 --- a/bookwyrm/tests/status/test_comment.py +++ /dev/null @@ -1,18 +0,0 @@ -from django.test import TestCase - -from bookwyrm import models -from bookwyrm import status as status_builder - - -class Comment(TestCase): - ''' we have hecka ways to create statuses ''' - def setUp(self): - self.user = models.User.objects.create_user( - 'mouse', 'mouse@mouse.mouse', 'mouseword') - self.book = models.Edition.objects.create(title='Example Edition') - - - def test_create_comment(self): - comment = status_builder.create_comment( - self.user, self.book, 'commentary') - self.assertEqual(comment.content, 'commentary') diff --git a/bookwyrm/tests/status/test_quotation.py b/bookwyrm/tests/status/test_quotation.py deleted file mode 100644 index 57755560a..000000000 --- a/bookwyrm/tests/status/test_quotation.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.test import TestCase -import json -import pathlib - -from bookwyrm import activitypub, models -from bookwyrm import status as status_builder - - -class Quotation(TestCase): - ''' we have hecka ways to create statuses ''' - def setUp(self): - self.user = models.User.objects.create_user( - 'mouse', 'mouse@mouse.mouse', 'mouseword', - remote_id='https://example.com/user/mouse' - ) - self.book = models.Edition.objects.create( - title='Example Edition', - remote_id='https://example.com/book/1', - ) - - - def test_create_quotation(self): - quotation = status_builder.create_quotation( - self.user, self.book, 'commentary', 'a quote') - self.assertEqual(quotation.quote, 'a quote') - self.assertEqual(quotation.content, 'commentary') diff --git a/bookwyrm/tests/status/test_review.py b/bookwyrm/tests/status/test_review.py deleted file mode 100644 index 263fef974..000000000 --- a/bookwyrm/tests/status/test_review.py +++ /dev/null @@ -1,39 +0,0 @@ -from django.test import TestCase - -from bookwyrm import models -from bookwyrm import status as status_builder - - -class Review(TestCase): - ''' we have hecka ways to create statuses ''' - def setUp(self): - self.user = models.User.objects.create_user( - 'mouse', 'mouse@mouse.mouse', 'mouseword') - self.book = models.Edition.objects.create(title='Example Edition') - - - def test_create_review(self): - review = status_builder.create_review( - self.user, self.book, 'review name', 'content', 5) - self.assertEqual(review.name, 'review name') - self.assertEqual(review.content, 'content') - self.assertEqual(review.rating, 5) - - review = status_builder.create_review( - self.user, self.book, '
    review
    name', 'content', 5) - self.assertEqual(review.name, 'review name') - self.assertEqual(review.content, 'content') - self.assertEqual(review.rating, 5) - - def test_review_rating(self): - review = status_builder.create_review( - self.user, self.book, 'review name', 'content', -1) - self.assertEqual(review.name, 'review name') - self.assertEqual(review.content, 'content') - self.assertEqual(review.rating, None) - - review = status_builder.create_review( - self.user, self.book, 'review name', 'content', 6) - self.assertEqual(review.name, 'review name') - self.assertEqual(review.content, 'content') - self.assertEqual(review.rating, None) diff --git a/bookwyrm/tests/status/test_status.py b/bookwyrm/tests/status/test_status.py deleted file mode 100644 index cb49cb12d..000000000 --- a/bookwyrm/tests/status/test_status.py +++ /dev/null @@ -1,28 +0,0 @@ -from django.test import TestCase - -from bookwyrm import models -from bookwyrm import status as status_builder - - -class Status(TestCase): - ''' we have hecka ways to create statuses ''' - def setUp(self): - self.user = models.User.objects.create_user( - 'mouse', 'mouse@mouse.mouse', 'mouseword', - local=False, - inbox='https://example.com/user/mouse/inbox', - outbox='https://example.com/user/mouse/outbox', - remote_id='https://example.com/user/mouse' - ) - - - def test_create_status(self): - content = 'statuses are usually replies' - status = status_builder.create_status( - self.user, content) - self.assertEqual(status.content, content) - - reply = status_builder.create_status( - self.user, content, reply_parent=status) - self.assertEqual(reply.content, content) - self.assertEqual(reply.reply_parent, status) diff --git a/bookwyrm/tests/test_books_manager.py b/bookwyrm/tests/test_books_manager.py index 46186838e..039bdfc5b 100644 --- a/bookwyrm/tests/test_books_manager.py +++ b/bookwyrm/tests/test_books_manager.py @@ -15,6 +15,8 @@ class Book(TestCase): title='Example Edition', parent_work=self.work ) + self.work.default_edition = self.edition + self.work.save() self.connector = models.Connector.objects.create( identifier='test_connector', diff --git a/bookwyrm/tests/test_broadcast.py b/bookwyrm/tests/test_broadcast.py index 1112b3fa6..b80512193 100644 --- a/bookwyrm/tests/test_broadcast.py +++ b/bookwyrm/tests/test_broadcast.py @@ -1,3 +1,4 @@ +from unittest.mock import patch from django.test import TestCase from bookwyrm import models, broadcast @@ -6,44 +7,45 @@ from bookwyrm import models, broadcast class Book(TestCase): def setUp(self): self.user = models.User.objects.create_user( - 'mouse', 'mouse@mouse.mouse', 'mouseword') - - follower = models.User.objects.create_user( - 'rat', 'rat@mouse.mouse', 'ratword', local=False, - remote_id='http://example.com/u/1', - outbox='http://example.com/u/1/o', - shared_inbox='http://example.com/inbox', - inbox='http://example.com/u/1/inbox') - self.user.followers.add(follower) - - no_inbox_follower = models.User.objects.create_user( - 'hamster', 'hamster@mouse.mouse', 'hamword', - shared_inbox=None, local=False, - remote_id='http://example.com/u/2', - outbox='http://example.com/u/2/o', - inbox='http://example.com/u/2/inbox') - self.user.followers.add(no_inbox_follower) - - non_fr_follower = models.User.objects.create_user( - 'gerbil', 'gerb@mouse.mouse', 'gerbword', - remote_id='http://example.com/u/3', - outbox='http://example2.com/u/3/o', - inbox='http://example2.com/u/3/inbox', - shared_inbox='http://example2.com/inbox', - bookwyrm_user=False, local=False) - self.user.followers.add(non_fr_follower) + 'mouse', 'mouse@mouse.mouse', 'mouseword', local=True) local_follower = models.User.objects.create_user( - 'joe', 'joe@mouse.mouse', 'jeoword') + 'joe', 'joe@mouse.mouse', 'jeoword', local=True) self.user.followers.add(local_follower) - models.User.objects.create_user( - 'nutria', 'nutria@mouse.mouse', 'nuword', - remote_id='http://example.com/u/4', - outbox='http://example.com/u/4/o', - shared_inbox='http://example.com/inbox', - inbox='http://example.com/u/4/inbox', - local=False) + with patch('bookwyrm.models.user.set_remote_server.delay'): + follower = models.User.objects.create_user( + 'rat', 'rat@mouse.mouse', 'ratword', local=False, + remote_id='http://example.com/u/1', + outbox='http://example.com/u/1/o', + shared_inbox='http://example.com/inbox', + inbox='http://example.com/u/1/inbox') + self.user.followers.add(follower) + + no_inbox_follower = models.User.objects.create_user( + 'hamster', 'hamster@mouse.mouse', 'hamword', + shared_inbox=None, local=False, + remote_id='http://example.com/u/2', + outbox='http://example.com/u/2/o', + inbox='http://example.com/u/2/inbox') + self.user.followers.add(no_inbox_follower) + + non_fr_follower = models.User.objects.create_user( + 'gerbil', 'gerb@mouse.mouse', 'gerbword', + remote_id='http://example.com/u/3', + outbox='http://example2.com/u/3/o', + inbox='http://example2.com/u/3/inbox', + shared_inbox='http://example2.com/inbox', + bookwyrm_user=False, local=False) + self.user.followers.add(non_fr_follower) + + models.User.objects.create_user( + 'nutria', 'nutria@mouse.mouse', 'nuword', + remote_id='http://example.com/u/4', + outbox='http://example.com/u/4/o', + shared_inbox='http://example.com/inbox', + inbox='http://example.com/u/4/inbox', + local=False) def test_get_public_recipients(self): diff --git a/bookwyrm/tests/test_remote_user.py b/bookwyrm/tests/test_remote_user.py deleted file mode 100644 index 3af8f59c0..000000000 --- a/bookwyrm/tests/test_remote_user.py +++ /dev/null @@ -1,70 +0,0 @@ -import json -import pathlib -from django.test import TestCase - -from bookwyrm import models, remote_user - - -class RemoteUser(TestCase): - ''' not too much going on in the books model but here we are ''' - def setUp(self): - self.remote_user = models.User.objects.create_user( - 'rat', 'rat@rat.com', 'ratword', - local=False, - remote_id='https://example.com/users/rat', - inbox='https://example.com/users/rat/inbox', - outbox='https://example.com/users/rat/outbox', - ) - datafile = pathlib.Path(__file__).parent.joinpath( - 'data/ap_user.json' - ) - self.user_data = json.loads(datafile.read_bytes()) - - - - def test_get_remote_user(self): - actor = 'https://example.com/users/rat' - user = remote_user.get_or_create_remote_user(actor) - self.assertEqual(user, self.remote_user) - - - def test_create_remote_user(self): - user = remote_user.create_remote_user(self.user_data) - self.assertFalse(user.local) - self.assertEqual(user.remote_id, 'https://example.com/user/mouse') - self.assertEqual(user.username, 'mouse@example.com') - self.assertEqual(user.name, 'MOUSE?? MOUSE!!') - self.assertEqual(user.inbox, 'https://example.com/user/mouse/inbox') - self.assertEqual(user.outbox, 'https://example.com/user/mouse/outbox') - self.assertEqual(user.shared_inbox, 'https://example.com/inbox') - self.assertEqual( - user.public_key, - self.user_data['publicKey']['publicKeyPem'] - ) - self.assertEqual(user.local, False) - self.assertEqual(user.bookwyrm_user, True) - self.assertEqual(user.manually_approves_followers, False) - - - def test_create_remote_user_missing_inbox(self): - del self.user_data['inbox'] - self.assertRaises( - TypeError, - remote_user.create_remote_user, - self.user_data - ) - - - def test_create_remote_user_missing_outbox(self): - del self.user_data['outbox'] - self.assertRaises( - TypeError, - remote_user.create_remote_user, - self.user_data - ) - - - def test_create_remote_user_default_fields(self): - del self.user_data['manuallyApprovesFollowers'] - user = remote_user.create_remote_user(self.user_data) - self.assertEqual(user.manually_approves_followers, False) diff --git a/bookwyrm/tests/test_signing.py b/bookwyrm/tests/test_signing.py index 7373c76f7..bf2527647 100644 --- a/bookwyrm/tests/test_signing.py +++ b/bookwyrm/tests/test_signing.py @@ -2,10 +2,13 @@ import time from collections import namedtuple from urllib.parse import urlsplit import pathlib +from unittest.mock import patch import json import responses +import pytest + from django.test import TestCase, Client from django.utils.http import http_date @@ -22,23 +25,27 @@ def get_follow_data(follower, followee): ).serialize() return json.dumps(follow_activity) -Sender = namedtuple('Sender', ('remote_id', 'private_key', 'public_key')) +KeyPair = namedtuple('KeyPair', ('private_key', 'public_key')) +Sender = namedtuple('Sender', ('remote_id', 'key_pair')) class Signature(TestCase): def setUp(self): - self.mouse = User.objects.create_user('mouse', 'mouse@example.com', '') - self.rat = User.objects.create_user('rat', 'rat@example.com', '') - self.cat = User.objects.create_user('cat', 'cat@example.com', '') + self.mouse = User.objects.create_user( + 'mouse', 'mouse@example.com', '', local=True) + self.rat = User.objects.create_user( + 'rat', 'rat@example.com', '', local=True) + self.cat = User.objects.create_user( + 'cat', 'cat@example.com', '', local=True) private_key, public_key = create_key_pair() self.fake_remote = Sender( 'http://localhost/user/remote', - private_key, - public_key, + KeyPair(private_key, public_key) ) def send(self, signature, now, data, digest): + ''' test request ''' c = Client() return c.post( urlsplit(self.rat.inbox).path, @@ -60,12 +67,15 @@ class Signature(TestCase): send_data=None, digest=None, date=None): + ''' sends a follow request to the "rat" user ''' now = date or http_date() - data = get_follow_data(sender, self.rat) + data = json.dumps(get_follow_data(sender, self.rat)) digest = digest or make_digest(data) signature = make_signature( signer or sender, self.rat.inbox, now, digest) - return self.send(signature, now, send_data or data, digest) + with patch('bookwyrm.incoming.handle_follow.delay'): + with patch('bookwyrm.models.user.set_remote_server.delay'): + return self.send(signature, now, send_data or data, digest) def test_correct_signature(self): response = self.send_test_request(sender=self.mouse) @@ -73,17 +83,17 @@ class Signature(TestCase): def test_wrong_signature(self): ''' Messages must be signed by the right actor. - (cat cannot sign messages on behalf of mouse) - ''' + (cat cannot sign messages on behalf of mouse) ''' response = self.send_test_request(sender=self.mouse, signer=self.cat) self.assertEqual(response.status_code, 401) @responses.activate def test_remote_signer(self): + ''' signtures for remote users ''' datafile = pathlib.Path(__file__).parent.joinpath('data/ap_user.json') data = json.loads(datafile.read_bytes()) data['id'] = self.fake_remote.remote_id - data['publicKey']['publicKeyPem'] = self.fake_remote.public_key + data['publicKey']['publicKeyPem'] = self.fake_remote.key_pair.public_key del data['icon'] # Avoid having to return an avatar. responses.add( responses.GET, @@ -101,15 +111,16 @@ class Signature(TestCase): status=200 ) - response = self.send_test_request(sender=self.fake_remote) - self.assertEqual(response.status_code, 200) + with patch('bookwyrm.models.user.get_remote_reviews.delay'): + response = self.send_test_request(sender=self.fake_remote) + self.assertEqual(response.status_code, 200) @responses.activate def test_key_needs_refresh(self): datafile = pathlib.Path(__file__).parent.joinpath('data/ap_user.json') data = json.loads(datafile.read_bytes()) data['id'] = self.fake_remote.remote_id - data['publicKey']['publicKeyPem'] = self.fake_remote.public_key + data['publicKey']['publicKeyPem'] = self.fake_remote.key_pair.public_key del data['icon'] # Avoid having to return an avatar. responses.add( responses.GET, @@ -120,40 +131,33 @@ class Signature(TestCase): responses.GET, 'https://localhost/.well-known/nodeinfo', status=404) - responses.add( - responses.GET, - 'https://example.com/user/mouse/outbox?page=true', - json={'orderedItems': []}, - status=200 - ) # Second and subsequent fetches get a different key: - new_private_key, new_public_key = create_key_pair() - new_sender = Sender( - self.fake_remote.remote_id, new_private_key, new_public_key) - data['publicKey']['publicKeyPem'] = new_public_key + key_pair = KeyPair(*create_key_pair()) + new_sender = Sender(self.fake_remote.remote_id, key_pair) + data['publicKey']['publicKeyPem'] = key_pair.public_key responses.add( responses.GET, self.fake_remote.remote_id, json=data, status=200) + with patch('bookwyrm.models.user.get_remote_reviews.delay'): + # Key correct: + response = self.send_test_request(sender=self.fake_remote) + self.assertEqual(response.status_code, 200) - # Key correct: - response = self.send_test_request(sender=self.fake_remote) - self.assertEqual(response.status_code, 200) + # Old key is cached, so still works: + response = self.send_test_request(sender=self.fake_remote) + self.assertEqual(response.status_code, 200) - # Old key is cached, so still works: - response = self.send_test_request(sender=self.fake_remote) - self.assertEqual(response.status_code, 200) + # Try with new key: + response = self.send_test_request(sender=new_sender) + self.assertEqual(response.status_code, 200) - # Try with new key: - response = self.send_test_request(sender=new_sender) - self.assertEqual(response.status_code, 200) - - # Now the old key will fail: - response = self.send_test_request(sender=self.fake_remote) - self.assertEqual(response.status_code, 401) + # Now the old key will fail: + response = self.send_test_request(sender=self.fake_remote) + self.assertEqual(response.status_code, 401) @responses.activate @@ -167,23 +171,29 @@ class Signature(TestCase): response = self.send_test_request(sender=self.fake_remote) self.assertEqual(response.status_code, 401) + @pytest.mark.integration def test_changed_data(self): '''Message data must match the digest header.''' - response = self.send_test_request( - self.mouse, - send_data=get_follow_data(self.mouse, self.cat)) - self.assertEqual(response.status_code, 401) + with patch('bookwyrm.activitypub.resolve_remote_id'): + response = self.send_test_request( + self.mouse, + send_data=get_follow_data(self.mouse, self.cat)) + self.assertEqual(response.status_code, 401) + @pytest.mark.integration def test_invalid_digest(self): - response = self.send_test_request( - self.mouse, - digest='SHA-256=AAAAAAAAAAAAAAAAAA') - self.assertEqual(response.status_code, 401) + with patch('bookwyrm.activitypub.resolve_remote_id'): + response = self.send_test_request( + self.mouse, + digest='SHA-256=AAAAAAAAAAAAAAAAAA') + self.assertEqual(response.status_code, 401) + @pytest.mark.integration def test_old_message(self): '''Old messages should be rejected to prevent replay attacks.''' - response = self.send_test_request( - self.mouse, - date=http_date(time.time() - 301) - ) - self.assertEqual(response.status_code, 401) + with patch('bookwyrm.activitypub.resolve_remote_id'): + response = self.send_test_request( + self.mouse, + date=http_date(time.time() - 301) + ) + self.assertEqual(response.status_code, 401) diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index f1b33877a..a9792038e 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -6,12 +6,19 @@ from django.urls import path, re_path from bookwyrm import incoming, outgoing, views, settings, wellknown from bookwyrm import view_actions as actions -username_regex = r'(?P[\w\-_]+@[\w\-\_\.]+)' -localname_regex = r'(?P[\w\-_]+)' +username_regex = r'(?P[\w\-_\.]+@[\w\-\_\.]+)' +localname_regex = r'(?P[\w\-_\.]+)' user_path = r'^user/%s' % username_regex local_user_path = r'^user/%s' % localname_regex -status_types = ['status', 'review', 'comment', 'quotation', 'boost'] +status_types = [ + 'status', + 'review', + 'comment', + 'quotation', + 'boost', + 'generatednote' +] status_path = r'%s/(%s)/(?P\d+)' % \ (local_user_path, '|'.join(status_types)) @@ -47,15 +54,15 @@ urlpatterns = [ path('', views.home), re_path(r'^(?Phome|local|federated)/?$', views.home_tab), re_path(r'^notifications/?', views.notifications_page), - re_path(r'import/?$', views.import_page), - re_path(r'import_status/(\d+)/?$', views.import_status), - re_path(r'user-edit/?$', views.edit_profile_page), + re_path(r'^import/?$', views.import_page), + re_path(r'^import-status/(\d+)/?$', views.import_status), + re_path(r'^user-edit/?$', views.edit_profile_page), # should return a ui view or activitypub json blob as requested # users re_path(r'%s/?$' % user_path, views.user_page), - re_path(r'%s/?$' % local_user_path, views.user_page), re_path(r'%s\.json$' % local_user_path, views.user_page), + re_path(r'%s/?$' % local_user_path, views.user_page), re_path(r'%s/shelves/?$' % local_user_path, views.user_shelves_page), re_path(r'%s/followers(.json)?/?$' % local_user_path, views.followers_page), re_path(r'%s/following(.json)?/?$' % local_user_path, views.following_page), @@ -68,10 +75,10 @@ urlpatterns = [ # books re_path(r'%s(.json)?/?$' % book_path, views.book_page), re_path(r'%s/edit/?$' % book_path, views.edit_book_page), - re_path(r'^editions/(?P\d+)/?$', views.editions_page), + re_path(r'%s/editions(.json)?/?$' % book_path, views.editions_page), re_path(r'^author/(?P[\w\-]+)(.json)?/?$', views.author_page), - # TODO: tag needs a .json path + re_path(r'^tag/(?P.+)\.json/?$', views.tag_page), re_path(r'^tag/(?P.+)/?$', views.tag_page), re_path(r'^%s/shelf/(?P[\w-]+)(.json)?/?$' % \ user_path, views.shelf_page), @@ -88,16 +95,21 @@ urlpatterns = [ re_path(r'^reset-password/?$', actions.password_reset), re_path(r'^change-password/?$', actions.password_change), - re_path(r'^edit_profile/?$', actions.edit_profile), + re_path(r'^edit-profile/?$', actions.edit_profile), - re_path(r'^import_data/?', actions.import_data), - re_path(r'^resolve_book/?', actions.resolve_book), - re_path(r'^edit_book/(?P\d+)/?', actions.edit_book), - re_path(r'^upload_cover/(?P\d+)/?', actions.upload_cover), + re_path(r'^import-data/?', actions.import_data), + re_path(r'^retry-import/?', actions.retry_import), + re_path(r'^resolve-book/?', actions.resolve_book), + re_path(r'^edit-book/(?P\d+)/?', actions.edit_book), + re_path(r'^upload-cover/(?P\d+)/?', actions.upload_cover), + re_path(r'^add-description/(?P\d+)/?', actions.add_description), + + re_path(r'^edit-readthrough/?', actions.edit_readthrough), + re_path(r'^delete-readthrough/?', actions.delete_readthrough), re_path(r'^rate/?$', actions.rate), re_path(r'^review/?$', actions.review), - re_path(r'^quotate/?$', actions.quotate), + re_path(r'^quote/?$', actions.quotate), re_path(r'^comment/?$', actions.comment), re_path(r'^tag/?$', actions.tag), re_path(r'^untag/?$', actions.untag), @@ -106,16 +118,25 @@ urlpatterns = [ re_path(r'^favorite/(?P\d+)/?$', actions.favorite), re_path(r'^unfavorite/(?P\d+)/?$', actions.unfavorite), re_path(r'^boost/(?P\d+)/?$', actions.boost), + re_path(r'^unboost/(?P\d+)/?$', actions.unboost), + re_path(r'^delete-status/(?P\d+)/?$', actions.delete_status), + + re_path(r'^create-shelf/?$', actions.create_shelf), + re_path(r'^edit-shelf/(?P\d+)?$', actions.edit_shelf), + re_path(r'^delete-shelf/(?P\d+)?$', actions.delete_shelf), re_path(r'^shelve/?$', actions.shelve), + re_path(r'^unshelve/?$', actions.unshelve), + re_path(r'^start-reading/(?P\d+)/?$', actions.start_reading), + re_path(r'^finish-reading/(?P\d+)/?$', actions.finish_reading), re_path(r'^follow/?$', actions.follow), re_path(r'^unfollow/?$', actions.unfollow), - re_path(r'^accept_follow_request/?$', actions.accept_follow_request), - re_path(r'^delete_follow_request/?$', actions.delete_follow_request), + re_path(r'^accept-follow-request/?$', actions.accept_follow_request), + re_path(r'^delete-follow-request/?$', actions.delete_follow_request), re_path(r'^clear-notifications/?$', actions.clear_notifications), - re_path(r'^create_invite/?$', actions.create_invite), + re_path(r'^create-invite/?$', actions.create_invite), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/bookwyrm/utils/__init__.py b/bookwyrm/utils/__init__.py index e69de29bb..a90554c70 100644 --- a/bookwyrm/utils/__init__.py +++ b/bookwyrm/utils/__init__.py @@ -0,0 +1 @@ +from .regex import username diff --git a/bookwyrm/utils/regex.py b/bookwyrm/utils/regex.py new file mode 100644 index 000000000..70a43b849 --- /dev/null +++ b/bookwyrm/utils/regex.py @@ -0,0 +1,5 @@ +''' defining regexes for regularly used concepts ''' + +domain = r'[a-z-A-Z0-9_\-]+\.[a-z]+' +username = r'@[a-zA-Z_\-\.0-9]+(@%s)?' % domain +full_username = r'@?[a-zA-Z_\-\.0-9]+@%s' % domain diff --git a/bookwyrm/view_actions.py b/bookwyrm/view_actions.py index 992a270dd..7126b1b22 100644 --- a/bookwyrm/view_actions.py +++ b/bookwyrm/view_actions.py @@ -1,14 +1,20 @@ ''' views for actions you can take in the application ''' from io import BytesIO, TextIOWrapper +from uuid import uuid4 from PIL import Image +import dateutil.parser +from dateutil.parser import ParserError + from django.contrib.auth import authenticate, login, logout from django.contrib.auth.decorators import login_required, permission_required +from django.core.exceptions import PermissionDenied from django.core.files.base import ContentFile from django.http import HttpResponseBadRequest, HttpResponseNotFound -from django.shortcuts import redirect +from django.shortcuts import get_object_or_404, redirect from django.template.response import TemplateResponse -from django.core.exceptions import PermissionDenied +from django.utils import timezone +from django.views.decorators.http import require_GET, require_POST from bookwyrm import books_manager from bookwyrm import forms, models, outgoing @@ -18,53 +24,40 @@ from bookwyrm.settings import DOMAIN from bookwyrm.views import get_user_from_username +@require_POST def user_login(request): ''' authenticate user login ''' - if request.method == 'GET': - return redirect('/login') - login_form = forms.LoginForm(request.POST) - register_form = forms.RegisterForm() - if not login_form.is_valid(): - data = { - 'site_settings': models.SiteSettings.get(), - 'login_form': login_form, - 'register_form': register_form - } - return TemplateResponse(request, 'login.html', data) username = login_form.data['username'] username = '%s@%s' % (username, DOMAIN) password = login_form.data['password'] user = authenticate(request, username=username, password=password) if user is not None: + # successful login login(request, user) + user.last_active_date = timezone.now() return redirect(request.GET.get('next', '/')) login_form.non_field_errors = 'Username or password are incorrect' + register_form = forms.RegisterForm() data = { - 'site_settings': models.SiteSettings.get(), 'login_form': login_form, 'register_form': register_form } return TemplateResponse(request, 'login.html', data) +@require_POST def register(request): ''' join the server ''' - if request.method == 'GET': - return redirect('/login') - if not models.SiteSettings.get().allow_registration: invite_code = request.POST.get('invite_code') if not invite_code: raise PermissionDenied - try: - invite = models.SiteInvite.objects.get(code=invite_code) - except models.SiteInvite.DoesNotExist: - raise PermissionDenied + invite = get_object_or_404(models.SiteInvite, code=invite_code) else: invite = None @@ -84,13 +77,13 @@ def register(request): if errors: data = { - 'site_settings': models.SiteSettings.get(), 'login_form': forms.LoginForm(), 'register_form': form } return TemplateResponse(request, 'login.html', data) - user = models.User.objects.create_user(username, email, password) + user = models.User.objects.create_user( + username, email, password, local=True) if invite: invite.times_used += 1 invite.save() @@ -100,12 +93,14 @@ def register(request): @login_required +@require_GET def user_logout(request): ''' done with this place! outa here! ''' logout(request) return redirect('/') +@require_POST def password_reset_request(request): ''' create a password reset token ''' email = request.POST.get('email') @@ -124,6 +119,7 @@ def password_reset_request(request): return TemplateResponse(request, 'password_reset_request.html', data) +@require_POST def password_reset(request): ''' allow a user to change their password through an emailed token ''' try: @@ -151,6 +147,7 @@ def password_reset(request): @login_required +@require_POST def password_change(request): ''' allow a user to change their password ''' new_password = request.POST.get('password') @@ -166,11 +163,9 @@ def password_change(request): @login_required +@require_POST def edit_profile(request): ''' les get fancy with images ''' - if not request.method == 'POST': - return redirect('/user/%s' % request.user.localname) - form = forms.EditUserForm(request.POST, request.FILES) if not form.is_valid(): data = { @@ -202,8 +197,12 @@ def edit_profile(request): output = BytesIO() cropped.save(output, format=image.format) ContentFile(output.getvalue()) + + # set the name to a hash + extension = form.files['avatar'].name.split('.')[-1] + filename = '%s.%s' % (uuid4(), extension) request.user.avatar.save( - form.files['avatar'].name, + filename, ContentFile(output.getvalue()) ) @@ -219,25 +218,27 @@ def edit_profile(request): def resolve_book(request): ''' figure out the local path to a book from a remote_id ''' remote_id = request.POST.get('remote_id') - book = books_manager.get_or_create_book(remote_id) + connector = books_manager.get_or_create_connector(remote_id) + book = connector.get_or_create_book(remote_id) + return redirect('/book/%d' % book.id) @login_required @permission_required('bookwyrm.edit_book', raise_exception=True) +@require_POST def edit_book(request, book_id): ''' edit a book cool ''' - if not request.method == 'POST': - return redirect('/book/%s' % book_id) - - try: - book = models.Edition.objects.get(id=book_id) - except models.Edition.DoesNotExist: - return HttpResponseNotFound() + book = get_object_or_404(models.Edition, id=book_id) form = forms.EditionForm(request.POST, request.FILES, instance=book) if not form.is_valid(): - return redirect(request.headers.get('Referer', '/')) + data = { + 'title': 'Edit Book', + 'book': book, + 'form': form + } + return TemplateResponse(request, 'edit_book.html', data) form.save() outgoing.handle_update_book(request.user, book) @@ -245,20 +246,14 @@ def edit_book(request, book_id): @login_required +@require_POST def upload_cover(request, book_id): ''' upload a new cover ''' - # TODO: alternate covers? - if not request.method == 'POST': - return redirect('/book/%s' % request.user.localname) - - try: - book = models.Edition.objects.get(id=book_id) - except models.Edition.DoesNotExist: - return HttpResponseNotFound() + book = get_object_or_404(models.Edition, id=book_id) form = forms.CoverForm(request.POST, request.FILES, instance=book) if not form.is_valid(): - return redirect(request.headers.get('Referer', '/')) + return redirect('/book/%d' % book.id) book.cover = form.files['cover'] book.sync_cover = False @@ -269,6 +264,67 @@ def upload_cover(request, book_id): @login_required +@require_POST +@permission_required('bookwyrm.edit_book', raise_exception=True) +def add_description(request, book_id): + ''' upload a new cover ''' + if not request.method == 'POST': + return redirect('/') + + book = get_object_or_404(models.Edition, id=book_id) + + description = request.POST.get('description') + + book.description = description + book.save() + + outgoing.handle_update_book(request.user, book) + return redirect('/book/%s' % book.id) + + +@login_required +@require_POST +def create_shelf(request): + ''' user generated shelves ''' + form = forms.ShelfForm(request.POST) + if not form.is_valid(): + return redirect(request.headers.get('Referer', '/')) + + shelf = form.save() + return redirect('/user/%s/shelf/%s' % \ + (request.user.localname, shelf.identifier)) + + +@login_required +@require_POST +def edit_shelf(request, shelf_id): + ''' user generated shelves ''' + shelf = get_object_or_404(models.Shelf, id=shelf_id) + if request.user != shelf.user: + return HttpResponseBadRequest() + + form = forms.ShelfForm(request.POST, instance=shelf) + if not form.is_valid(): + return redirect(request.headers.get('Referer', '/')) + shelf = form.save() + return redirect('/user/%s/shelf/%s' % \ + (request.user.localname, shelf.identifier)) + + +@login_required +@require_POST +def delete_shelf(request, shelf_id): + ''' user generated shelves ''' + shelf = get_object_or_404(models.Shelf, id=shelf_id) + if request.user != shelf.user or not shelf.editable: + return HttpResponseBadRequest() + + shelf.delete() + return redirect('/user/%s/shelves' % request.user.localname) + + +@login_required +@require_POST def shelve(request): ''' put a on a user's shelf ''' book = books_manager.get_edition(request.POST['book']) @@ -289,91 +345,207 @@ def shelve(request): # this just means it isn't currently on the user's shelves pass outgoing.handle_shelve(request.user, book, desired_shelf) + + # post about "want to read" shelves + if desired_shelf.identifier == 'to-read': + outgoing.handle_reading_status( + request.user, + desired_shelf, + book, + privacy='public' + ) + return redirect('/') @login_required +@require_POST +def unshelve(request): + ''' put a on a user's shelf ''' + book = models.Edition.objects.get(id=request.POST['book']) + current_shelf = models.Shelf.objects.get(id=request.POST['shelf']) + + outgoing.handle_unshelve(request.user, book, current_shelf) + return redirect(request.headers.get('Referer', '/')) + + +@login_required +@require_POST +def start_reading(request, book_id): + ''' begin reading a book ''' + book = books_manager.get_edition(book_id) + shelf = models.Shelf.objects.filter( + identifier='reading', + user=request.user + ).first() + + # create a readthrough + readthrough = update_readthrough(request, book=book) + if readthrough.start_date: + readthrough.save() + + # shelve the book + if request.POST.get('reshelve', True): + try: + current_shelf = models.Shelf.objects.get( + user=request.user, + edition=book + ) + outgoing.handle_unshelve(request.user, book, current_shelf) + except models.Shelf.DoesNotExist: + # this just means it isn't currently on the user's shelves + pass + outgoing.handle_shelve(request.user, book, shelf) + + # post about it (if you want) + if request.POST.get('post-status'): + privacy = request.POST.get('privacy') + outgoing.handle_reading_status(request.user, shelf, book, privacy) + + return redirect(request.headers.get('Referer', '/')) + + +@login_required +@require_POST +def finish_reading(request, book_id): + ''' a user completed a book, yay ''' + book = books_manager.get_edition(book_id) + shelf = models.Shelf.objects.filter( + identifier='read', + user=request.user + ).first() + + # update or create a readthrough + readthrough = update_readthrough(request, book=book) + if readthrough.start_date or readthrough.finish_date: + readthrough.save() + + # shelve the book + if request.POST.get('reshelve', True): + try: + current_shelf = models.Shelf.objects.get( + user=request.user, + edition=book + ) + outgoing.handle_unshelve(request.user, book, current_shelf) + except models.Shelf.DoesNotExist: + # this just means it isn't currently on the user's shelves + pass + outgoing.handle_shelve(request.user, book, shelf) + + # post about it (if you want) + if request.POST.get('post-status'): + privacy = request.POST.get('privacy') + outgoing.handle_reading_status(request.user, shelf, book, privacy) + + return redirect(request.headers.get('Referer', '/')) + + +@login_required +@require_POST +def edit_readthrough(request): + ''' can't use the form because the dates are too finnicky ''' + readthrough = update_readthrough(request, create=False) + if not readthrough: + return HttpResponseNotFound() + + # don't let people edit other people's data + if request.user != readthrough.user: + return HttpResponseBadRequest() + readthrough.save() + + return redirect(request.headers.get('Referer', '/')) + + +@login_required +@require_POST +def delete_readthrough(request): + ''' remove a readthrough ''' + readthrough = get_object_or_404( + models.ReadThrough, id=request.POST.get('id')) + + # don't let people edit other people's data + if request.user != readthrough.user: + return HttpResponseBadRequest() + + readthrough.delete() + return redirect(request.headers.get('Referer', '/')) + + +@login_required +@require_POST def rate(request): ''' just a star rating for a book ''' form = forms.RatingForm(request.POST) - book_id = request.POST.get('book') - # TODO: better failure behavior - if not form.is_valid(): - return redirect('/book/%s' % book_id) - - rating = form.cleaned_data.get('rating') - # throws a value error if the book is not found - - outgoing.handle_rate(request.user, book_id, rating) - return redirect('/book/%s' % book_id) + return handle_status(request, form) @login_required +@require_POST def review(request): ''' create a book review ''' form = forms.ReviewForm(request.POST) - book_id = request.POST.get('book') - if not form.is_valid(): - return redirect('/book/%s' % book_id) - - # TODO: validation, htmlification - name = form.cleaned_data.get('name') - content = form.cleaned_data.get('content') - rating = form.data.get('rating', None) - try: - rating = int(rating) - except ValueError: - rating = None - - outgoing.handle_review(request.user, book_id, name, content, rating) - return redirect('/book/%s' % book_id) + return handle_status(request, form) @login_required +@require_POST def quotate(request): ''' create a book quotation ''' form = forms.QuotationForm(request.POST) - book_id = request.POST.get('book') - if not form.is_valid(): - return redirect('/book/%s' % book_id) - - quote = form.cleaned_data.get('quote') - content = form.cleaned_data.get('content') - - outgoing.handle_quotation(request.user, book_id, content, quote) - return redirect('/book/%s' % book_id) + return handle_status(request, form) @login_required +@require_POST def comment(request): ''' create a book comment ''' form = forms.CommentForm(request.POST) - book_id = request.POST.get('book') - # TODO: better failure behavior - if not form.is_valid(): - return redirect('/book/%s' % book_id) - - # TODO: validation, htmlification - content = form.data.get('content') - - outgoing.handle_comment(request.user, book_id, content) - return redirect('/book/%s' % book_id) + return handle_status(request, form) @login_required +@require_POST +def reply(request): + ''' respond to a book review ''' + form = forms.ReplyForm(request.POST) + return handle_status(request, form) + + +def handle_status(request, form): + ''' all the "create a status" functions are the same ''' + if not form.is_valid(): + return redirect(request.headers.get('Referer', '/')) + + outgoing.handle_status(request.user, form) + return redirect(request.headers.get('Referer', '/')) + + +@login_required +@require_POST def tag(request): ''' tag a book ''' # I'm not using a form here because sometimes "name" is sent as a hidden # field which doesn't validate name = request.POST.get('name') book_id = request.POST.get('book') - remote_id = 'https://%s/book/%s' % (DOMAIN, book_id) + book = get_object_or_404(models.Edition, id=book_id) + tag_obj, created = models.Tag.objects.get_or_create( + name=name, + ) + user_tag = models.UserTag.objects.get_or_create( + user=request.user, + book=book, + tag=tag_obj, + ) - outgoing.handle_tag(request.user, remote_id, name) + if created: + outgoing.handle_tag(request.user, user_tag) return redirect('/book/%s' % book_id) @login_required +@require_POST def untag(request): ''' untag a book ''' name = request.POST.get('name') @@ -384,19 +556,7 @@ def untag(request): @login_required -def reply(request): - ''' respond to a book review ''' - form = forms.ReplyForm(request.POST) - # this is a bit of a formality, the form is just one text field - if not form.is_valid(): - return redirect('/') - parent_id = request.POST['parent'] - parent = models.Status.objects.get(id=parent_id) - outgoing.handle_reply(request.user, parent, form.data['content']) - return redirect('/') - - -@login_required +@require_POST def favorite(request, status_id): ''' like a status ''' status = models.Status.objects.get(id=status_id) @@ -405,20 +565,49 @@ def favorite(request, status_id): @login_required +@require_POST def unfavorite(request, status_id): ''' like a status ''' status = models.Status.objects.get(id=status_id) outgoing.handle_unfavorite(request.user, status) return redirect(request.headers.get('Referer', '/')) + @login_required +@require_POST def boost(request, status_id): ''' boost a status ''' status = models.Status.objects.get(id=status_id) outgoing.handle_boost(request.user, status) return redirect(request.headers.get('Referer', '/')) + @login_required +@require_POST +def unboost(request, status_id): + ''' boost a status ''' + status = models.Status.objects.get(id=status_id) + outgoing.handle_unboost(request.user, status) + return redirect(request.headers.get('Referer', '/')) + + +@login_required +@require_POST +def delete_status(request, status_id): + ''' delete and tombstone a status ''' + status = get_object_or_404(models.Status, id=status_id) + + # don't let people delete other people's statuses + if status.user != request.user: + return HttpResponseBadRequest() + + # perform deletion + outgoing.handle_delete_status(request.user, status) + return redirect(request.headers.get('Referer', '/')) + + +@login_required +@require_POST def follow(request): ''' follow another user, here or abroad ''' username = request.POST['user'] @@ -434,6 +623,7 @@ def follow(request): @login_required +@require_POST def unfollow(request): ''' unfollow a user ''' username = request.POST['user'] @@ -456,6 +646,7 @@ def clear_notifications(request): @login_required +@require_POST def accept_follow_request(request): ''' a user accepts a follow request ''' username = request.POST['user'] @@ -473,12 +664,13 @@ def accept_follow_request(request): # Request already dealt with. pass else: - outgoing.handle_accept(requester, request.user, follow_request) + outgoing.handle_accept(follow_request) return redirect('/user/%s' % request.user.localname) @login_required +@require_POST def delete_follow_request(request): ''' a user rejects a follow request ''' username = request.POST['user'] @@ -495,30 +687,54 @@ def delete_follow_request(request): except models.UserFollowRequest.DoesNotExist: return HttpResponseBadRequest() - outgoing.handle_reject(requester, request.user, follow_request) + outgoing.handle_reject(follow_request) return redirect('/user/%s' % request.user.localname) @login_required +@require_POST def import_data(request): ''' ingest a goodreads csv ''' form = forms.ImportForm(request.POST, request.FILES) if form.is_valid(): + include_reviews = request.POST.get('include_reviews') == 'on' + privacy = request.POST.get('privacy') try: job = goodreads_import.create_job( request.user, TextIOWrapper( request.FILES['csv_file'], - encoding=request.encoding) + encoding=request.encoding), + include_reviews, + privacy, ) except (UnicodeDecodeError, ValueError): return HttpResponseBadRequest('Not a valid csv file') goodreads_import.start_import(job) - return redirect('/import_status/%d' % (job.id,)) + return redirect('/import-status/%d' % job.id) return HttpResponseBadRequest() @login_required +@require_POST +def retry_import(request): + ''' ingest a goodreads csv ''' + job = get_object_or_404(models.ImportJob, id=request.POST.get('import_job')) + items = [] + for item in request.POST.getlist('import_item'): + items.append(get_object_or_404(models.ImportItem, id=item)) + + job = goodreads_import.create_retry_job( + request.user, + job, + items, + ) + goodreads_import.start_import(job) + return redirect('/import-status/%d' % job.id) + + +@login_required +@require_POST @permission_required('bookwyrm.create_invites', raise_exception=True) def create_invite(request): ''' creates a user invite database entry ''' @@ -531,3 +747,37 @@ def create_invite(request): invite.save() return redirect('/invite') + + +def update_readthrough(request, book=None, create=True): + ''' updates but does not save dates on a readthrough ''' + try: + read_id = request.POST.get('id') + if not read_id: + raise models.ReadThrough.DoesNotExist + readthrough = models.ReadThrough.objects.get(id=read_id) + except models.ReadThrough.DoesNotExist: + if not create or not book: + return None + readthrough = models.ReadThrough( + user=request.user, + book=book, + ) + + start_date = request.POST.get('start_date') + if start_date: + try: + start_date = dateutil.parser.parse(start_date) + readthrough.start_date = start_date + except ParserError: + pass + + finish_date = request.POST.get('finish_date') + if finish_date: + try: + finish_date = dateutil.parser.parse(finish_date) + readthrough.finish_date = finish_date + except ParserError: + pass + + return readthrough diff --git a/bookwyrm/views.py b/bookwyrm/views.py index 2bc840c0c..562f575ef 100644 --- a/bookwyrm/views.py +++ b/bookwyrm/views.py @@ -2,19 +2,23 @@ import re from django.contrib.auth.decorators import login_required, permission_required -from django.db.models import Avg, Count, Q -from django.http import HttpResponseBadRequest, HttpResponseNotFound,\ - JsonResponse +from django.contrib.postgres.search import TrigramSimilarity +from django.core.paginator import Paginator +from django.db.models import Avg, Q +from django.http import HttpResponseNotFound, JsonResponse from django.core.exceptions import PermissionDenied -from django.shortcuts import redirect +from django.shortcuts import get_object_or_404, redirect from django.template.response import TemplateResponse from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_GET from bookwyrm import outgoing from bookwyrm.activitypub import ActivityEncoder from bookwyrm import forms, models, books_manager from bookwyrm import goodreads_import +from bookwyrm.settings import PAGE_LENGTH from bookwyrm.tasks import app +from bookwyrm.utils import regex def get_user_from_username(username): @@ -34,141 +38,172 @@ def is_api_request(request): def server_error_page(request): ''' 500 errors ''' - return TemplateResponse(request, 'error.html') + return TemplateResponse(request, 'error.html', {'title': 'Oops!'}) def not_found_page(request, _): ''' 404s ''' - return TemplateResponse(request, 'notfound.html') + return TemplateResponse(request, 'notfound.html', {'title': 'Not found'}) @login_required +@require_GET def home(request): ''' this is the same as the feed on the home tab ''' return home_tab(request, 'home') @login_required +@require_GET def home_tab(request, tab): ''' user's homepage with activity feed ''' - # TODO: why on earth would this be where the pagination is set - page_size = 15 try: page = int(request.GET.get('page', 1)) except ValueError: page = 1 - count = 5 - querysets = [ - # recemt currently reading - models.Edition.objects.filter( - shelves__user=request.user, - shelves__identifier='reading' - ), - # read - models.Edition.objects.filter( - shelves__user=request.user, - shelves__identifier='read' - )[:2], - # to-read - models.Edition.objects.filter( - shelves__user=request.user, - shelves__identifier='to-read' - ), - # popular books - models.Edition.objects.annotate( - shelf_count=Count('shelves') - ).order_by('-shelf_count') - ] - suggested_books = [] - for queryset in querysets: - length = count - len(suggested_books) - suggested_books += list(queryset[:length]) - if len(suggested_books) >= count: - break + suggested_books = get_suggested_books(request.user) activities = get_activity_feed(request.user, tab) + paginated = Paginator(activities, PAGE_LENGTH) + activity_page = paginated.page(page) - activity_count = activities.count() - activities = activities[(page - 1) * page_size:page * page_size] - - next_page = '/?page=%d#feed' % (page + 1) - prev_page = '/?page=%d#feed' % (page - 1) + prev_page = next_page = None + if activity_page.has_next(): + next_page = '/%s/?page=%d#feed' % \ + (tab, activity_page.next_page_number()) + if activity_page.has_previous(): + prev_page = '/%s/?page=%d#feed' % \ + (tab, activity_page.previous_page_number()) data = { + 'title': 'Updates Feed', 'user': request.user, 'suggested_books': suggested_books, - 'activities': activities, - 'review_form': forms.ReviewForm(), - 'quotation_form': forms.QuotationForm(), + 'activities': activity_page.object_list, 'tab': tab, - 'comment_form': forms.CommentForm(), - 'next': next_page if activity_count > (page_size * page) else None, - 'prev': prev_page if page > 1 else None, + 'next': next_page, + 'prev': prev_page, } return TemplateResponse(request, 'feed.html', data) +def get_suggested_books(user, max_books=5): + ''' helper to get a user's recent books ''' + book_count = 0 + preset_shelves = [ + ('reading', max_books), ('read', 2), ('to-read', max_books) + ] + suggested_books = [] + for (preset, shelf_max) in preset_shelves: + limit = shelf_max if shelf_max < (max_books - book_count) \ + else max_books - book_count + shelf = user.shelf_set.get(identifier=preset) + + shelf_books = shelf.shelfbook_set.order_by( + '-updated_date' + ).all()[:limit] + if not shelf_books: + continue + shelf_preview = { + 'name': shelf.name, + 'books': [s.book for s in shelf_books] + } + suggested_books.append(shelf_preview) + book_count += len(shelf_preview['books']) + return suggested_books + + def get_activity_feed(user, filter_level, model=models.Status): ''' get a filtered queryset of statuses ''' # status updates for your follow network - following = models.User.objects.filter( - Q(followers=user) | Q(id=user.id) - ) + if user.is_anonymous: + user = None + if user: + following = models.User.objects.filter( + Q(followers=user) | Q(id=user.id) + ) + else: + following = [] activities = model if hasattr(model, 'objects'): activities = model.objects - activities = activities.order_by( - '-created_date' + activities = activities.filter( + deleted=False, + ).order_by( + '-published_date' ) + if hasattr(activities, 'select_subclasses'): activities = activities.select_subclasses() - # TODO: privacy relationshup between request.user and user if filter_level in ['friends', 'home']: # people you follow and direct mentions activities = activities.filter( - Q(user__in=following, privacy='public') | \ - Q(mention_users=user) + Q(user__in=following, privacy__in=[ + 'public', 'unlisted', 'followers' + ]) | Q(mention_users=user) | Q(user=user) ) elif filter_level == 'self': activities = activities.filter(user=user, privacy='public') elif filter_level == 'local': - # everyone on this instance - activities = activities.filter(user__local=True, privacy='public') + # everyone on this instance except unlisted + activities = activities.filter( + Q(user__in=following, privacy='followers') | Q(privacy='public'), + user__local=True + ) else: # all activities from everyone you federate with - activities = activities.filter(privacy='public') + activities = activities.filter( + Q(user__in=following, privacy='followers') | Q(privacy='public') + ) + + try: + activities = activities.filter(~Q(boosters__in=activities)) + except ValueError: + pass return activities +@require_GET def search(request): ''' that search bar up top ''' query = request.GET.get('q') - if re.match(r'\w+@\w+.\w+', query): - # if something looks like a username, search with webfinger - results = outgoing.handle_account_search(query) - return TemplateResponse( - request, 'user_results.html', {'results': results, 'query': query} - ) - - # or just send the question over to book search if is_api_request(request): - # only return local results via json so we don't cause a cascade - results = books_manager.local_search(query) - return JsonResponse([r.__dict__ for r in results], safe=False) + # only return local book results via json so we don't cause a cascade + book_results = books_manager.local_search(query) + return JsonResponse([r.__dict__ for r in book_results], safe=False) - results = books_manager.search(query) - return TemplateResponse(request, 'book_results.html', {'results': results}) + # use webfinger for mastodon style account@domain.com username + if re.match(regex.full_username, query): + outgoing.handle_remote_webfinger(query) + + # do a local user search + user_results = models.User.objects.annotate( + similarity=TrigramSimilarity('username', query), + ).filter( + similarity__gt=0.5, + ).order_by('-similarity')[:10] + + book_results = books_manager.search(query) + data = { + 'title': 'Search Results', + 'book_results': book_results, + 'user_results': user_results, + 'query': query, + } + return TemplateResponse(request, 'search_results.html', data) @login_required +@require_GET def import_page(request): ''' import history from goodreads ''' return TemplateResponse(request, 'import.html', { + 'title': 'Import Books', 'import_form': forms.ImportForm(), 'jobs': models.ImportJob. objects.filter(user=request.user).order_by('-created_date'), @@ -177,45 +212,59 @@ def import_page(request): @login_required +@require_GET def import_status(request, job_id): ''' status of an import job ''' job = models.ImportJob.objects.get(id=job_id) if job.user != request.user: raise PermissionDenied task = app.AsyncResult(job.task_id) + items = job.items.order_by('index').all() + failed_items = [i for i in items if i.fail_reason] + items = [i for i in items if not i.fail_reason] return TemplateResponse(request, 'import_status.html', { + 'title': 'Import Status', 'job': job, - 'items': job.items.order_by('index').all(), + 'items': items, + 'failed_items': failed_items, 'task': task }) +@require_GET def login_page(request): ''' authentication ''' if request.user.is_authenticated: return redirect('/') # send user to the login page data = { - 'site_settings': models.SiteSettings.get(), + 'title': 'Login', 'login_form': forms.LoginForm(), 'register_form': forms.RegisterForm(), } return TemplateResponse(request, 'login.html', data) +@require_GET def about_page(request): ''' more information about the instance ''' data = { - 'site_settings': models.SiteSettings.get(), + 'title': 'About', } return TemplateResponse(request, 'about.html', data) +@require_GET def password_reset_request(request): ''' invite management page ''' - return TemplateResponse(request, 'password_reset_request.html') + return TemplateResponse( + request, + 'password_reset_request.html', + {'title': 'Reset Password'} + ) +@require_GET def password_reset(request, code): ''' endpoint for sending invites ''' if request.user.is_authenticated: @@ -230,10 +279,11 @@ def password_reset(request, code): return TemplateResponse( request, 'password_reset.html', - {'code': reset_code.code} + {'title': 'Reset Password', 'code': reset_code.code} ) +@require_GET def invite_page(request, code): ''' endpoint for sending invites ''' if request.user.is_authenticated: @@ -246,17 +296,20 @@ def invite_page(request, code): raise PermissionDenied data = { - 'site_settings': models.SiteSettings.get(), + 'title': 'Join', 'register_form': forms.RegisterForm(), 'invite': invite, } return TemplateResponse(request, 'invite.html', data) + @login_required @permission_required('bookwyrm.create_invites', raise_exception=True) +@require_GET def manage_invites(request): ''' invite management page ''' data = { + 'title': 'Invitations', 'invites': models.SiteInvite.objects.filter(user=request.user), 'form': forms.CreateInviteForm(), } @@ -264,20 +317,24 @@ def manage_invites(request): @login_required +@require_GET def notifications_page(request): ''' list notitications ''' notifications = request.user.notification_set.all() \ .order_by('-created_date') unread = [n.id for n in notifications.filter(read=False)] data = { + 'title': 'Notifications', 'notifications': notifications, 'unread': unread, } notifications.update(read=True) return TemplateResponse(request, 'notifications.html', data) + @csrf_exempt -def user_page(request, username, subpage=None): +@require_GET +def user_page(request, username): ''' profile page for a user ''' try: user = get_user_from_username(username) @@ -289,45 +346,65 @@ def user_page(request, username, subpage=None): return JsonResponse(user.to_activity(), encoder=ActivityEncoder) # otherwise we're at a UI view - data = { - 'user': user, - 'is_self': request.user.id == user.id, - } - if subpage == 'followers': - data['followers'] = user.followers.all() - return TemplateResponse(request, 'followers.html', data) - if subpage == 'following': - data['following'] = user.following.all() - return TemplateResponse(request, 'following.html', data) - if subpage == 'shelves': - data['shelves'] = user.shelf_set.all() - return TemplateResponse(request, 'user_shelves.html', data) + try: + page = int(request.GET.get('page', 1)) + except ValueError: + page = 1 - data['shelf_count'] = user.shelf_set.count() - shelves = [] - for shelf in user.shelf_set.all(): - if not shelf.books.count(): + shelf_preview = [] + + # only show other shelves that should be visible + shelves = user.shelf_set + is_self = request.user.id == user.id + if not is_self: + follower = user.followers.filter(id=request.user.id).exists() + if follower: + shelves = shelves.filter(privacy__in=['public', 'followers']) + else: + shelves = shelves.filter(privacy='public') + + for user_shelf in shelves.all(): + if not user_shelf.books.count(): continue - shelves.append({ - 'name': shelf.name, - 'remote_id': shelf.remote_id, - 'books': shelf.books.all()[:3], - 'size': shelf.books.count(), + shelf_preview.append({ + 'name': user_shelf.name, + 'remote_id': user_shelf.remote_id, + 'books': user_shelf.books.all()[:3], + 'size': user_shelf.books.count(), }) - if len(shelves) > 2: + if len(shelf_preview) > 2: break - data['shelves'] = shelves - data['activities'] = get_activity_feed(user, 'self')[:15] + # user's posts + activities = get_activity_feed(user, 'self') + paginated = Paginator(activities, PAGE_LENGTH) + activity_page = paginated.page(page) + + prev_page = next_page = None + if activity_page.has_next(): + next_page = '/user/%s/?page=%d' % \ + (username, activity_page.next_page_number()) + if activity_page.has_previous(): + prev_page = '/user/%s/?page=%d' % \ + (username, activity_page.previous_page_number()) + data = { + 'title': user.name, + 'user': user, + 'is_self': is_self, + 'shelves': shelf_preview, + 'shelf_count': shelves.count(), + 'activities': activity_page.object_list, + 'next': next_page, + 'prev': prev_page, + } + return TemplateResponse(request, 'user.html', data) @csrf_exempt +@require_GET def followers_page(request, username): ''' list of followers ''' - if request.method != 'GET': - return HttpResponseBadRequest() - try: user = get_user_from_username(username) except models.User.DoesNotExist: @@ -336,15 +413,19 @@ def followers_page(request, username): if is_api_request(request): return JsonResponse(user.to_followers_activity(**request.GET)) - return user_page(request, username, subpage='followers') + data = { + 'title': '%s: followers' % user.name, + 'user': user, + 'is_self': request.user.id == user.id, + 'followers': user.followers.all(), + } + return TemplateResponse(request, 'followers.html', data) @csrf_exempt +@require_GET def following_page(request, username): ''' list of followers ''' - if request.method != 'GET': - return HttpResponseBadRequest() - try: user = get_user_from_username(username) except models.User.DoesNotExist: @@ -353,48 +434,60 @@ def following_page(request, username): if is_api_request(request): return JsonResponse(user.to_following_activity(**request.GET)) - return user_page(request, username, subpage='following') - - -@csrf_exempt -def user_shelves_page(request, username): - ''' list of followers ''' - if request.method != 'GET': - return HttpResponseBadRequest() - - return user_page(request, username, subpage='shelves') + data = { + 'title': '%s: following' % user.name, + 'user': user, + 'is_self': request.user.id == user.id, + 'following': user.following.all(), + } + return TemplateResponse(request, 'following.html', data) @csrf_exempt +@require_GET def status_page(request, username, status_id): ''' display a particular status (and replies, etc) ''' - if request.method != 'GET': - return HttpResponseBadRequest() - try: user = get_user_from_username(username) status = models.Status.objects.select_subclasses().get(id=status_id) except ValueError: return HttpResponseNotFound() + # the url should have the poster's username in it if user != status.user: return HttpResponseNotFound() + # make sure the user is authorized to see the status + if not status_visible_to_user(request.user, status): + return HttpResponseNotFound() + if is_api_request(request): return JsonResponse(status.to_activity(), encoder=ActivityEncoder) data = { + 'title': 'Status by %s' % user.username, 'status': status, } return TemplateResponse(request, 'status.html', data) +def status_visible_to_user(viewer, status): + ''' is a user authorized to view a status? ''' + if viewer == status.user or status.privacy in ['public', 'unlisted']: + return True + if status.privacy == 'followers' and \ + status.user.followers.filter(id=viewer.id).first(): + return True + if status.privacy == 'direct' and \ + status.mention_users.filter(id=viewer.id).first(): + return True + return False + + @csrf_exempt +@require_GET def replies_page(request, username, status_id): ''' ordered collection of replies to a status ''' - if request.method != 'GET': - return HttpResponseBadRequest() - if not is_api_request(request): return status_page(request, username, status_id) @@ -409,26 +502,38 @@ def replies_page(request, username, status_id): @login_required +@require_GET def edit_profile_page(request): ''' profile page for a user ''' user = request.user form = forms.EditUserForm(instance=request.user) data = { + 'title': 'Edit profile', 'form': form, 'user': user, } return TemplateResponse(request, 'edit_user.html', data) +@require_GET def book_page(request, book_id): ''' info about a book ''' - book = models.Book.objects.select_subclasses().get(id=book_id) + try: + page = int(request.GET.get('page', 1)) + except ValueError: + page = 1 + + try: + book = models.Book.objects.select_subclasses().get(id=book_id) + except models.Book.DoesNotExist: + return HttpResponseNotFound() + if is_api_request(request): return JsonResponse(book.to_activity(), encoder=ActivityEncoder) if isinstance(book, models.Work): - book = book.default_edition + book = book.get_default_edition() if not book: return HttpResponseNotFound() @@ -437,35 +542,50 @@ def book_page(request, book_id): return HttpResponseNotFound() reviews = models.Review.objects.filter( - book__in=work.edition_set.all(), - ).order_by('-published_date') + book__in=work.editions.all(), + ) + # all reviews for the book + reviews = get_activity_feed(request.user, 'federated', model=reviews) + + # the reviews to show + paginated = Paginator(reviews.filter(content__isnull=False), PAGE_LENGTH) + reviews_page = paginated.page(page) + + prev_page = next_page = None + if reviews_page.has_next(): + next_page = '/book/%d/?page=%d' % \ + (book_id, reviews_page.next_page_number()) + if reviews_page.has_previous(): + prev_page = '/book/%s/?page=%d' % \ + (book_id, reviews_page.previous_page_number()) user_tags = [] + readthroughs = [] if request.user.is_authenticated: - user_tags = models.Tag.objects.filter( + user_tags = models.UserTag.objects.filter( book=book, user=request.user - ).values_list('identifier', flat=True) + ).values_list('tag__identifier', flat=True) + + readthroughs = models.ReadThrough.objects.filter( + user=request.user, + book=book, + ).order_by('start_date') rating = reviews.aggregate(Avg('rating')) - tags = models.Tag.objects.filter( - book=book - ).values( - 'book', 'name', 'identifier' - ).distinct().all() + tags = models.UserTag.objects.filter( + book=book, + ) data = { + 'title': book.title, 'book': book, - 'reviews': reviews.filter(content__isnull=False), + 'reviews': reviews_page, 'ratings': reviews.filter(content__isnull=True), 'rating': rating['rating__avg'], 'tags': tags, 'user_tags': user_tags, - 'review_form': forms.ReviewForm(), - 'quotation_form': forms.QuotationForm(), - 'comment_form': forms.CommentForm(), - 'tag_form': forms.TagForm(), + 'readthroughs': readthroughs, 'path': '/book/%s' % book_id, - 'cover_form': forms.CoverForm(instance=book), 'info_fields': [ {'name': 'ISBN', 'value': book.isbn_13}, {'name': 'OCLC number', 'value': book.oclc_number}, @@ -474,53 +594,66 @@ def book_page(request, book_id): {'name': 'Format', 'value': book.physical_format}, {'name': 'Pages', 'value': book.pages}, ], + 'next': next_page, + 'prev': prev_page, } return TemplateResponse(request, 'book.html', data) @login_required @permission_required('bookwyrm.edit_book', raise_exception=True) +@require_GET def edit_book_page(request, book_id): ''' info about a book ''' book = books_manager.get_edition(book_id) if not book.description: book.description = book.parent_work.description data = { + 'title': 'Edit Book', 'book': book, 'form': forms.EditionForm(instance=book) } return TemplateResponse(request, 'edit_book.html', data) -def editions_page(request, work_id): +@require_GET +def editions_page(request, book_id): ''' list of editions of a book ''' - work = models.Work.objects.get(id=work_id) + work = get_object_or_404(models.Work, id=book_id) + + if is_api_request(request): + return JsonResponse( + work.to_edition_list(**request.GET), + encoder=ActivityEncoder + ) + editions = models.Edition.objects.filter(parent_work=work).all() data = { + 'title': 'Editions of %s' % work.title, 'editions': editions, 'work': work, } return TemplateResponse(request, 'editions.html', data) +@require_GET def author_page(request, author_id): ''' landing page for an author ''' - try: - author = models.Author.objects.get(id=author_id) - except ValueError: - return HttpResponseNotFound() + author = get_object_or_404(models.Author, id=author_id) if is_api_request(request): return JsonResponse(author.to_activity(), encoder=ActivityEncoder) books = models.Work.objects.filter(authors=author) data = { + 'title': author.name, 'author': author, - 'books': [b.default_edition for b in books], + 'books': [b.get_default_edition() for b in books], } return TemplateResponse(request, 'author.html', data) +@require_GET def tag_page(request, tag_id): ''' books related to a tag ''' tag_obj = models.Tag.objects.filter(identifier=tag_id).first() @@ -531,14 +664,25 @@ def tag_page(request, tag_id): return JsonResponse( tag_obj.to_activity(**request.GET), encoder=ActivityEncoder) - books = models.Edition.objects.filter(tag__identifier=tag_id).distinct() + books = models.Edition.objects.filter( + usertag__tag__identifier=tag_id + ).distinct() data = { + 'title': tag_obj.name, 'books': books, 'tag': tag_obj, } return TemplateResponse(request, 'tag.html', data) +@csrf_exempt +@require_GET +def user_shelves_page(request, username): + ''' list of followers ''' + return shelf_page(request, username, None) + + +@require_GET def shelf_page(request, username, shelf_identifier): ''' display a shelf ''' try: @@ -546,13 +690,37 @@ def shelf_page(request, username, shelf_identifier): except models.User.DoesNotExist: return HttpResponseNotFound() - shelf = models.Shelf.objects.get(user=user, identifier=shelf_identifier) + if shelf_identifier: + shelf = user.shelf_set.get(identifier=shelf_identifier) + else: + shelf = user.shelf_set.first() + + is_self = request.user == user + + shelves = user.shelf_set + if not is_self: + follower = user.followers.filter(id=request.user.id).exists() + # make sure the user has permission to view the shelf + if shelf.privacy == 'direct' or \ + (shelf.privacy == 'followers' and not follower): + return HttpResponseNotFound() + + # only show other shelves that should be visible + if follower: + shelves = shelves.filter(privacy__in=['public', 'followers']) + else: + shelves = shelves.filter(privacy='public') + if is_api_request(request): return JsonResponse(shelf.to_activity(**request.GET)) data = { - 'shelf': shelf, + 'title': user.name, 'user': user, + 'is_self': is_self, + 'shelves': shelves.all(), + 'shelf': shelf, } + return TemplateResponse(request, 'shelf.html', data) diff --git a/bookwyrm/wellknown.py b/bookwyrm/wellknown.py index b59256fcb..ec8557ddf 100644 --- a/bookwyrm/wellknown.py +++ b/bookwyrm/wellknown.py @@ -1,6 +1,9 @@ ''' responds to various requests to /.well-know ''' -from django.http import HttpResponseBadRequest, HttpResponseNotFound + +from dateutil.relativedelta import relativedelta +from django.http import HttpResponseNotFound from django.http import JsonResponse +from django.utils import timezone from bookwyrm import models from bookwyrm.settings import DOMAIN @@ -13,11 +16,14 @@ def webfinger(request): resource = request.GET.get('resource') if not resource and not resource.startswith('acct:'): - return HttpResponseBadRequest() - ap_id = resource.replace('acct:', '') - user = models.User.objects.filter(username=ap_id).first() - if not user: + return HttpResponseNotFound() + + username = resource.replace('acct:', '') + try: + user = models.User.objects.get(username=username) + except models.User.DoesNotExist: return HttpResponseNotFound('No account found') + return JsonResponse({ 'subject': 'acct:%s' % (user.username), 'links': [ @@ -51,7 +57,21 @@ def nodeinfo(request): return HttpResponseNotFound() status_count = models.Status.objects.filter(user__local=True).count() - user_count = models.User.objects.count() + user_count = models.User.objects.filter(local=True).count() + + month_ago = timezone.now() - relativedelta(months=1) + last_month_count = models.User.objects.filter( + local=True, + last_active_date__gt=month_ago + ).count() + + six_months_ago = timezone.now() - relativedelta(months=6) + six_month_count = models.User.objects.filter( + local=True, + last_active_date__gt=six_months_ago + ).count() + + site = models.SiteSettings.get() return JsonResponse({ 'version': '2.0', 'software': { @@ -64,38 +84,39 @@ def nodeinfo(request): 'usage': { 'users': { 'total': user_count, - 'activeMonth': user_count, # TODO - 'activeHalfyear': user_count, # TODO + 'activeMonth': last_month_count, + 'activeHalfyear': six_month_count, }, 'localPosts': status_count, }, - 'openRegistrations': True, + 'openRegistrations': site.allow_registration, }) def instance_info(request): - ''' what this place is TODO: should be settable/editable ''' + ''' let's talk about your cool unique instance ''' if request.method != 'GET': return HttpResponseNotFound() - user_count = models.User.objects.count() - status_count = models.Status.objects.count() + user_count = models.User.objects.filter(local=True).count() + status_count = models.Status.objects.filter(user__local=True).count() + + site = models.SiteSettings.get() return JsonResponse({ 'uri': DOMAIN, - 'title': 'BookWyrm', - 'short_description': 'Social reading, decentralized', - 'description': '', - 'email': 'mousereeve@riseup.net', + 'title': site.name, + 'short_description': '', + 'description': site.instance_description, 'version': '0.0.1', 'stats': { 'user_count': user_count, 'status_count': status_count, }, - 'thumbnail': '', # TODO: logo thumbnail + 'thumbnail': 'https://%s/static/images/logo.png' % DOMAIN, 'languages': [ 'en' ], - 'registrations': True, + 'registrations': site.allow_registration, 'approval_required': False, }) diff --git a/bw-dev b/bw-dev new file mode 100755 index 000000000..53c8e52d9 --- /dev/null +++ b/bw-dev @@ -0,0 +1,102 @@ +#!/bin/bash + +# exit on errors +set -e + +# import our ENV variables +# catch exits and give a friendly error message +function showerr { + echo "Failed to load configuration! You may need to update your .env and quote values with special characters in them." +} +trap showerr EXIT +source .env +trap - EXIT + +# show commands as they're executed +set -x + +function clean { + docker-compose stop + docker-compose rm -f +} + +function runweb { + docker-compose run --rm web "$@" + clean +} + +function execdb { + docker-compose exec db $@ +} + +function execweb { + docker-compose exec web "$@" +} + +function initdb { + execweb python manage.py migrate + execweb python manage.py initdb +} + +case "$1" in + up) + docker-compose up --build + ;; + run) + docker-compose run --rm --service-ports web + ;; + initdb) + initdb + ;; + resetdb) + clean + docker-compose up --build -d + execdb dropdb -U ${POSTGRES_USER} ${POSTGRES_DB} + execdb createdb -U ${POSTGRES_USER} ${POSTGRES_DB} + initdb + clean + ;; + makemigrations) + execweb python manage.py makemigrations + ;; + migrate) + execweb python manage.py rename_app fedireads bookwyrm + shift 1 + execweb python manage.py migrate "$@" + ;; + bash) + execweb bash + ;; + shell) + execweb python manage.py shell + ;; + dbshell) + execdb psql -U ${POSTGRES_USER} ${POSTGRES_DB} + ;; + restart_celery) + docker-compose restart celery_worker + ;; + test) + shift 1 + execweb coverage run --source='.' --omit="*/test*,celerywyrm*,bookwyrm/migrations/*" manage.py test "$@" + ;; + pytest) + shift 1 + execweb pytest "$@" + ;; + test_report) + execweb coverage report + ;; + collectstatic) + execweb python manage.py collectstatic --no-input + ;; + build) + docker-compose build + ;; + clean) + clean + ;; + *) + echo "Unrecognised command. Try: build, clean, up, initdb, resetdb, makemigrations, migrate, bash, shell, dbshell, restart_celery, test, pytest, test_report" + ;; +esac diff --git a/celerywyrm/celery.py b/celerywyrm/celery.py index 28b4a2005..efa081ee8 100644 --- a/celerywyrm/celery.py +++ b/celerywyrm/celery.py @@ -19,8 +19,10 @@ app.config_from_object('django.conf:settings', namespace='CELERY') # Load task modules from all registered Django app configs. app.autodiscover_tasks() -app.autodiscover_tasks(['bookwyrm'], related_name='broadcast') +app.autodiscover_tasks(['bookwyrm'], related_name='activitypub.base_activity') app.autodiscover_tasks(['bookwyrm'], related_name='books_manager') +app.autodiscover_tasks(['bookwyrm'], related_name='broadcast') app.autodiscover_tasks(['bookwyrm'], related_name='emailing') app.autodiscover_tasks(['bookwyrm'], related_name='goodreads_import') app.autodiscover_tasks(['bookwyrm'], related_name='incoming') +app.autodiscover_tasks(['bookwyrm'], related_name='models.user') diff --git a/docker-compose.yml b/docker-compose.yml index 6f9dbdc2a..08567ce33 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '3' services: nginx: - build: ./nginx + image: nginx:latest ports: - 1333:80 depends_on: @@ -10,6 +10,7 @@ services: networks: - main volumes: + - ./nginx:/etc/nginx/conf.d - static_volume:/app/static - media_volume:/app/images db: @@ -21,6 +22,7 @@ services: - main web: build: . + env_file: .env command: python manage.py runserver 0.0.0.0:8000 volumes: - .:/app @@ -37,7 +39,7 @@ services: image: redis env_file: .env ports: - - "6379:6379" + - 6379:6379 networks: - main restart: on-failure @@ -55,6 +57,20 @@ services: - db - redis restart: on-failure + flower: + build: . + command: flower --port=8888 + env_file: .env + environment: + - CELERY_BROKER_URL=${CELERY_BROKER} + networks: + - main + depends_on: + - db + - redis + restart: on-failure + ports: + - 8888:8888 volumes: pgdata: static_volume: diff --git a/fr-dev b/fr-dev deleted file mode 100755 index ae40e8891..000000000 --- a/fr-dev +++ /dev/null @@ -1,53 +0,0 @@ -#!/bin/bash - -set -e -set -x - -case "$1" in - up) - docker-compose up --build - ;; - run) - docker-compose run --service-ports web - ;; - initdb) - docker-compose exec web python manage.py migrate - docker-compose exec web python manage.py shell -c 'import init_db' - ;; - resetdb) - docker-compose stop web - docker-compose exec db dropdb -U fedireads fedireads - docker-compose exec db createdb -U fedireads fedireads - docker-compose start web - docker-compose exec web python manage.py migrate - docker-compose exec web python manage.py shell -c 'import init_db' - ;; - makemigrations) - docker-compose exec web python manage.py makemigrations - ;; - migrate) - docker-compose exec web python manage.py migrate - ;; - shell) - docker-compose exec web python manage.py shell - ;; - dbshell) - docker-compose exec db psql -U fedireads fedireads - ;; - restart_celery) - docker-compose restart celery_worker - ;; - test) - shift 1 - docker-compose exec web coverage run --source='.' manage.py test "$@" - ;; - test_report) - docker-compose exec web coverage report - ;; - collectstatic) - docker-compose exec web python manage.py collectstatic --no-input - ;; - *) - echo "Unrecognised command. Try: up, initdb, resetdb, makemigrations, migrate, shell, dbshell, restart_celery, test, test_report" - ;; -esac diff --git a/fr-dev b/fr-dev new file mode 120000 index 000000000..9947871eb --- /dev/null +++ b/fr-dev @@ -0,0 +1 @@ +bw-dev \ No newline at end of file diff --git a/init_db.py b/init_db.py deleted file mode 100644 index ef11f8c5f..000000000 --- a/init_db.py +++ /dev/null @@ -1,79 +0,0 @@ -''' starter data ''' -from django.contrib.auth.models import Group, Permission -from django.contrib.contenttypes.models import ContentType - -from bookwyrm.models import Connector, User -from bookwyrm.settings import DOMAIN - - -groups = ['admin', 'moderator', 'editor'] -for group in groups: - Group.objects.create(name=group) - -permissions = [{ - 'codename': 'edit_instance_settings', - 'name': 'change the instance info', - 'groups': ['admin',] - }, { - 'codename': 'set_user_group', - 'name': 'change what group a user is in', - 'groups': ['admin', 'moderator'] - }, { - 'codename': 'control_federation', - 'name': 'control who to federate with', - 'groups': ['admin', 'moderator'] - }, { - 'codename': 'create_invites', - 'name': 'issue invitations to join', - 'groups': ['admin', 'moderator'] - }, { - 'codename': 'moderate_user', - 'name': 'deactivate or silence a user', - 'groups': ['admin', 'moderator'] - }, { - 'codename': 'moderate_post', - 'name': 'delete other users\' posts', - 'groups': ['admin', 'moderator'] - }, { - 'codename': 'edit_book', - 'name': 'edit book info', - 'groups': ['admin', 'moderator', 'editor'] - }] - -content_type = ContentType.objects.get_for_model(User) -for permission in permissions: - permission_obj = Permission.objects.create( - codename=permission['codename'], - name=permission['name'], - content_type=content_type, - ) - # add the permission to the appropriate groups - for group_name in permission['groups']: - Group.objects.get(name=group_name).permissions.add(permission_obj) - -# while the groups and permissions shouldn't be changed because the code -# depends on them, what permissions go with what groups should be editable - - - -Connector.objects.create( - identifier=DOMAIN, - name='Local', - local=True, - connector_file='self_connector', - base_url='https://%s' % DOMAIN, - books_url='https://%s/book' % DOMAIN, - covers_url='https://%s/images/covers' % DOMAIN, - search_url='https://%s/search?q=' % DOMAIN, - priority=1, -) - -Connector.objects.create( - identifier='openlibrary.org', - name='OpenLibrary', - connector_file='openlibrary', - base_url='https://openlibrary.org', - books_url='https://openlibrary.org', - covers_url='https://covers.openlibrary.org', - search_url='https://openlibrary.org/search?q=', -) diff --git a/nginx/Dockerfile b/nginx/Dockerfile deleted file mode 100644 index 66074cf66..000000000 --- a/nginx/Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ -FROM nginx:1.17.4-alpine - -RUN rm /etc/nginx/conf.d/default.conf -COPY nginx.conf /etc/nginx/conf.d diff --git a/nginx/nginx.conf b/nginx/default.conf similarity index 100% rename from nginx/nginx.conf rename to nginx/default.conf diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..fa9dbc59f --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +DJANGO_SETTINGS_MODULE = bookwyrm.settings +python_files = tests.py test_*.py *_tests.py +addopts = --cov=bookwyrm --cov-config=.coveragerc +markers = + integration: marks tests as requiring external resources (deselect with '-m "not integration"') diff --git a/rebuilddb.sh b/rebuilddb.sh index 99be0268e..d32e5dab4 100755 --- a/rebuilddb.sh +++ b/rebuilddb.sh @@ -21,5 +21,5 @@ fi python manage.py makemigrations fedireads python manage.py migrate -python manage.py shell < init_db.py +python manage.py initdb python manage.py runserver diff --git a/requirements.txt b/requirements.txt index e041ec25d..0e17fccaf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,11 @@ flower==0.9.4 Pillow>=7.1.0 psycopg2==2.8.4 pycryptodome==3.9.4 +pytest-django==4.1.0 +pytest==6.1.2 +pytest-cov==2.10.1 python-dateutil==2.8.1 redis==3.4.1 requests==2.22.0 responses==0.10.14 +django-rename-app==0.1.2