Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • 1.4.1-upgrade-release
  • 1121-download
  • 1218-smartplaylist_backend
  • 1373-login-form-move-reset-your-password-link
  • 1381-progress-bars
  • 1481
  • 1518-update-django-allauth
  • 1645
  • 1675-widget-improperly-configured-missing-resource-id
  • 1675-widget-improperly-configured-missing-resource-id-2
  • 1704-required-props-are-not-always-passed
  • 1716-add-frontend-tests-again
  • 1749-smtp-uri-configuration
  • 1930-first-upload-in-a-batch-always-fails
  • 1976-update-documentation-links-in-readme-files
  • 2054-player-layout
  • 2063-funkwhale-connection-interrupted-every-so-often-requires-network-reset-page-refresh
  • 2091-iii-6-improve-visuals-layout
  • 2151-refused-to-load-spa-manifest-json-2
  • 2154-add-to-playlist-pop-up-hidden-by-now-playing-screen
  • 2155-can-t-see-the-episode-list-of-a-podcast-as-an-anonymous-user-with-anonymous-access-enabled
  • 2156-add-management-command-to-change-file-ref-for-in-place-imported-files-to-s3
  • 2192-clear-queue-bug-when-random-shuffle-is-enabled
  • 2205-channel-page-pagination-link-dont-working
  • 2215-custom-logger-does-not-work-at-all-with-webkit-and-blink-based-browsers
  • 2228-troi-real-world-review
  • 2274-implement-new-upload-api
  • 2303-allow-users-to-own-tagged-items
  • 2395-far-right-filter
  • 2405-front-buttont-trigger-third-party-hook
  • 2408-troi-create-missing-tracks
  • 2416-revert-library-drop
  • 2429-fix-popover-auto-close
  • 2448-complete-tags
  • 2452-fetch-third-party-metadata
  • 2467-fix-radio-builder
  • 2469-Fix-search-bar-in-ManageUploads
  • 2476-deep-upload-links
  • 2480-add-notification-number-badges
  • 2482-upgrade-about-page-to-use-new-ui
  • 2487-fix-accessibility-according-to-WCAG
  • 2490-experiment-use-rstore
  • 2490-experimental-use-simple-data-store
  • 2490-fix-search-modal
  • 2490-search-modal
  • 2501-fix-compatibility-with-older-browsers
  • 2502-drop-uno-and-jquery
  • 2533-allow-followers-in-user-activiy-privacy-level
  • 2539-drop-ansible-installation-method-in-favor-of-docker
  • 2550-22-user-interfaces-for-federation
  • 2560-default-modal-width
  • 623-test
  • 653-enable-starting-embedded-player-at-a-specific-position-in-track
  • activitypub-overview
  • album-sliders
  • arne/2091-improve-visuals
  • back-option-for-edits
  • chore/2406-compose-modularity-scope
  • develop
  • develop-password-reset
  • env-file-cleanup
  • feat/2091-improve-visuals
  • feature/2481-vui-translations
  • fix-amd64-docker-build-gfortran
  • fix-channel-creation
  • fix-front-node-version
  • fix-gitpod
  • fix-plugins-dev-setup
  • fix-rate-limit-serializer
  • fix-schema-channel-metadata-choices
  • flupsi/2803-improve-visuals
  • flupsi/2804-new-upload-process
  • funkwhale-fix_pwa_manifest
  • funkwhale-petitminion-2136-bug-fix-prune-skipped-upload
  • funkwhale-ui-buttons
  • georg/add-typescript
  • gitpod/test-1866
  • global-button-experiment
  • global-buttons
  • juniorjpdj/pkg-repo
  • manage-py-reference
  • merge-review
  • minimal-python-version
  • petitminion-develop-patch-84496
  • pin-mutagen-to-1.46
  • pipenv
  • plugins
  • plugins-v2
  • plugins-v3
  • pre-release/1.3.0
  • prune_skipped_uploads_docs
  • refactor/homepage
  • renovate/front-all-dependencies
  • renovate/front-major-all-dependencies
  • schema-updates
  • small-gitpod-improvements
  • spectacular_schema
  • stable
  • tempArne
  • ui-buttons
  • 0.1
  • 0.10
  • 0.11
  • 0.12
  • 0.13
  • 0.14
  • 0.14.1
  • 0.14.2
  • 0.15
  • 0.16
  • 0.16.1
  • 0.16.2
  • 0.16.3
  • 0.17
  • 0.18
  • 0.18.1
  • 0.18.2
  • 0.18.3
  • 0.19.0
  • 0.19.0-rc1
  • 0.19.0-rc2
  • 0.19.1
  • 0.2
  • 0.2.1
  • 0.2.2
  • 0.2.3
  • 0.2.4
  • 0.2.5
  • 0.2.6
  • 0.20.0
  • 0.20.0-rc1
  • 0.20.1
  • 0.21
  • 0.21-rc1
  • 0.21-rc2
  • 0.21.1
  • 0.21.2
  • 0.3
  • 0.3.1
  • 0.3.2
  • 0.3.3
  • 0.3.4
  • 0.3.5
  • 0.4
  • 0.5
  • 0.5.1
  • 0.5.2
  • 0.5.3
  • 0.5.4
  • 0.6
  • 0.6.1
  • 0.7
  • 0.8
  • 0.9
  • 0.9.1
  • 1.0
  • 1.0-rc1
  • 1.0.1
  • 1.1
  • 1.1-rc1
  • 1.1-rc2
  • 1.1.1
  • 1.1.2
  • 1.1.3
  • 1.1.4
  • 1.2.0
  • 1.2.0-rc1
  • 1.2.0-rc2
  • 1.2.0-testing
  • 1.2.0-testing2
  • 1.2.0-testing3
  • 1.2.0-testing4
  • 1.2.1
  • 1.2.10
  • 1.2.2
  • 1.2.3
  • 1.2.4
  • 1.2.5
  • 1.2.6
  • 1.2.6-1
  • 1.2.7
  • 1.2.8
  • 1.2.9
  • 1.3.0
  • 1.3.0-rc1
  • 1.3.0-rc2
  • 1.3.0-rc3
  • 1.3.0-rc4
  • 1.3.0-rc5
  • 1.3.0-rc6
  • 1.3.1
  • 1.3.2
  • 1.3.3
  • 1.3.4
  • 1.4.0
  • 1.4.0-rc1
  • 1.4.0-rc2
  • 1.4.1
  • 2.0.0-alpha.1
  • 2.0.0-alpha.2
200 results

Target

Select target project
  • funkwhale/funkwhale
  • Luclu7/funkwhale
  • mbothorel/funkwhale
  • EorlBruder/funkwhale
  • tcit/funkwhale
  • JocelynDelalande/funkwhale
  • eneiluj/funkwhale
  • reg/funkwhale
  • ButterflyOfFire/funkwhale
  • m4sk1n/funkwhale
  • wxcafe/funkwhale
  • andybalaam/funkwhale
  • jcgruenhage/funkwhale
  • pblayo/funkwhale
  • joshuaboniface/funkwhale
  • n3ddy/funkwhale
  • gegeweb/funkwhale
  • tohojo/funkwhale
  • emillumine/funkwhale
  • Te-k/funkwhale
  • asaintgenis/funkwhale
  • anoadragon453/funkwhale
  • Sakada/funkwhale
  • ilianaw/funkwhale
  • l4p1n/funkwhale
  • pnizet/funkwhale
  • dante383/funkwhale
  • interfect/funkwhale
  • akhardya/funkwhale
  • svfusion/funkwhale
  • noplanman/funkwhale
  • nykopol/funkwhale
  • roipoussiere/funkwhale
  • Von/funkwhale
  • aurieh/funkwhale
  • icaria36/funkwhale
  • floreal/funkwhale
  • paulwalko/funkwhale
  • comradekingu/funkwhale
  • FurryJulie/funkwhale
  • Legolars99/funkwhale
  • Vierkantor/funkwhale
  • zachhats/funkwhale
  • heyjake/funkwhale
  • sn0w/funkwhale
  • jvoisin/funkwhale
  • gordon/funkwhale
  • Alexander/funkwhale
  • bignose/funkwhale
  • qasim.ali/funkwhale
  • fakegit/funkwhale
  • Kxze/funkwhale
  • stenstad/funkwhale
  • creak/funkwhale
  • Kaze/funkwhale
  • Tixie/funkwhale
  • IISergII/funkwhale
  • lfuelling/funkwhale
  • nhaddag/funkwhale
  • yoasif/funkwhale
  • ifischer/funkwhale
  • keslerm/funkwhale
  • flupe/funkwhale
  • petitminion/funkwhale
  • ariasuni/funkwhale
  • ollie/funkwhale
  • ngaumont/funkwhale
  • techknowlogick/funkwhale
  • Shleeble/funkwhale
  • theflyingfrog/funkwhale
  • jonatron/funkwhale
  • neobrain/funkwhale
  • eorn/funkwhale
  • KokaKiwi/funkwhale
  • u1-liquid/funkwhale
  • marzzzello/funkwhale
  • sirenwatcher/funkwhale
  • newer027/funkwhale
  • codl/funkwhale
  • Zwordi/funkwhale
  • gisforgabriel/funkwhale
  • iuriatan/funkwhale
  • simon/funkwhale
  • bheesham/funkwhale
  • zeoses/funkwhale
  • accraze/funkwhale
  • meliurwen/funkwhale
  • divadsn/funkwhale
  • Etua/funkwhale
  • sdrik/funkwhale
  • Soran/funkwhale
  • kuba-orlik/funkwhale
  • cristianvogel/funkwhale
  • Forceu/funkwhale
  • jeff/funkwhale
  • der_scheibenhacker/funkwhale
  • owlnical/funkwhale
  • jovuit/funkwhale
  • SilverFox15/funkwhale
  • phw/funkwhale
  • mayhem/funkwhale
  • sridhar/funkwhale
  • stromlin/funkwhale
  • rrrnld/funkwhale
  • nitaibezerra/funkwhale
  • jaller94/funkwhale
  • pcouy/funkwhale
  • eduxstad/funkwhale
  • codingHahn/funkwhale
  • captain/funkwhale
  • polyedre/funkwhale
  • leishenailong/funkwhale
  • ccritter/funkwhale
  • lnceballosz/funkwhale
  • fpiesche/funkwhale
  • Fanyx/funkwhale
  • markusblogde/funkwhale
  • Firobe/funkwhale
  • devilcius/funkwhale
  • freaktechnik/funkwhale
  • blopware/funkwhale
  • cone/funkwhale
  • thanksd/funkwhale
  • vachan-maker/funkwhale
  • bbenti/funkwhale
  • tarator/funkwhale
  • prplecake/funkwhale
  • DMarzal/funkwhale
  • lullis/funkwhale
  • hanacgr/funkwhale
  • albjeremias/funkwhale
  • xeruf/funkwhale
  • llelite/funkwhale
  • RoiArthurB/funkwhale
  • cloo/funkwhale
  • nztvar/funkwhale
  • Keunes/funkwhale
  • petitminion/funkwhale-petitminion
  • m-idler/funkwhale
  • SkyLeite/funkwhale
140 results
Select Git revision
  • 303-json-ld
  • 629-cookie-auth
  • 735-table-truncate
  • develop
  • domain-policies
  • live-streaming
  • master
  • webdav
  • 0.1
  • 0.10
  • 0.11
  • 0.12
  • 0.13
  • 0.14
  • 0.14.1
  • 0.14.2
  • 0.15
  • 0.16
  • 0.16.1
  • 0.16.2
  • 0.16.3
  • 0.17
  • 0.18
  • 0.18.1
  • 0.18.2
  • 0.2
  • 0.2.1
  • 0.2.2
  • 0.2.3
  • 0.2.4
  • 0.2.5
  • 0.2.6
  • 0.3
  • 0.3.1
  • 0.3.2
  • 0.3.3
  • 0.3.4
  • 0.3.5
  • 0.4
  • 0.5
  • 0.5.1
  • 0.5.2
  • 0.5.3
  • 0.5.4
  • 0.6
  • 0.6.1
  • 0.7
  • 0.8
  • 0.9
  • 0.9.1
50 results
Show changes
Showing
with 6768 additions and 363 deletions
from funkwhale_api.moderation import mrf
from . import activity
@mrf.inbox.register(name="instance_policies")
def instance_policies(payload, **kwargs):
reject = activity.should_reject(
fid=payload.get("id"),
actor_id=kwargs.get("sender_id", payload.get("id")),
payload=payload,
)
if reject:
raise mrf.Discard()
from rest_framework.negotiation import BaseContentNegotiation
from rest_framework.renderers import JSONRenderer from rest_framework.renderers import JSONRenderer
...@@ -6,6 +7,7 @@ def get_ap_renderers(): ...@@ -6,6 +7,7 @@ def get_ap_renderers():
("APActivity", "application/activity+json"), ("APActivity", "application/activity+json"),
("APLD", "application/ld+json"), ("APLD", "application/ld+json"),
("APJSON", "application/json"), ("APJSON", "application/json"),
("HTML", "text/html"),
] ]
return [ return [
...@@ -14,5 +16,19 @@ def get_ap_renderers(): ...@@ -14,5 +16,19 @@ def get_ap_renderers():
] ]
class IgnoreClientContentNegotiation(BaseContentNegotiation):
def select_parser(self, request, parsers):
"""
Select the first parser in the `.parser_classes` list.
"""
return parsers[0]
def select_renderer(self, request, renderers, format_suffix):
"""
Select the first renderer in the `.renderer_classes` list.
"""
return (renderers[0], renderers[0].media_type)
class WebfingerRenderer(JSONRenderer): class WebfingerRenderer(JSONRenderer):
media_type = "application/jrd+json" media_type = "application/jrd+json"
import logging import logging
import uuid
from django.db.models import Q
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.history import models as history_models
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.playlists import models as playlist_models
from . import activity from . import activity, actors, models, serializers
from . import serializers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
inbox = activity.InboxRouter() inbox = activity.InboxRouter()
...@@ -77,6 +82,37 @@ def outbox_accept(context): ...@@ -77,6 +82,37 @@ def outbox_accept(context):
} }
@outbox.register({"type": "Reject"})
def outbox_reject_follow(context):
follow = context["follow"]
if follow._meta.label == "federation.LibraryFollow":
actor = follow.target.actor
else:
actor = follow.target
payload = serializers.RejectFollowSerializer(follow, context={"actor": actor}).data
yield {
"actor": actor,
"type": "Reject",
"payload": with_recipients(payload, to=[follow.actor]),
"object": follow,
"related_object": follow.target,
}
@inbox.register({"type": "Reject"})
def inbox_reject_follow(payload, context):
serializer = serializers.RejectFollowSerializer(data=payload, context=context)
if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
logger.debug(
"Discarding invalid follow reject from %s: %s",
context["actor"].fid,
serializer.errors,
)
return
serializer.save()
@inbox.register({"type": "Undo", "object.type": "Follow"}) @inbox.register({"type": "Undo", "object.type": "Follow"})
def inbox_undo_follow(payload, context): def inbox_undo_follow(payload, context):
serializer = serializers.UndoFollowSerializer(data=payload, context=context) serializer = serializers.UndoFollowSerializer(data=payload, context=context)
...@@ -129,37 +165,53 @@ def outbox_follow(context): ...@@ -129,37 +165,53 @@ def outbox_follow(context):
@outbox.register({"type": "Create", "object.type": "Audio"}) @outbox.register({"type": "Create", "object.type": "Audio"})
def outbox_create_audio(context): def outbox_create_audio(context):
upload = context["upload"] upload = context["upload"]
channel = upload.library.get_channel()
followers_target = channel.actor if channel else upload.library.actor
actor = channel.actor if channel else upload.library.actor
if channel:
serializer = serializers.ChannelCreateUploadSerializer(upload)
else:
upload_serializer = serializers.UploadSerializer
serializer = serializers.ActivitySerializer( serializer = serializers.ActivitySerializer(
{ {
"type": "Create", "type": "Create",
"actor": upload.library.actor.fid, "actor": actor.fid,
"object": serializers.UploadSerializer(upload).data, "object": upload_serializer(upload).data,
} }
) )
yield { yield {
"type": "Create", "type": "Create",
"actor": upload.library.actor, "actor": actor,
"payload": with_recipients( "payload": with_recipients(
serializer.data, to=[{"type": "followers", "target": upload.library}] serializer.data, to=[{"type": "followers", "target": followers_target}]
), ),
"object": upload, "object": upload,
"target": upload.library, "target": None if channel else upload.library,
} }
@inbox.register({"type": "Create", "object.type": "Audio"}) @inbox.register({"type": "Create", "object.type": "Audio"})
def inbox_create_audio(payload, context): def inbox_create_audio(payload, context):
is_channel = "library" not in payload["object"]
if is_channel:
channel = context["actor"].get_channel()
serializer = serializers.ChannelCreateUploadSerializer(
data=payload,
context={"channel": channel},
)
else:
serializer = serializers.UploadSerializer( serializer = serializers.UploadSerializer(
data=payload["object"], data=payload["object"],
context={"activity": context.get("activity"), "actor": context["actor"]}, context={"activity": context.get("activity"), "actor": context["actor"]},
) )
if not serializer.is_valid(raise_exception=context.get("raise_exception", False)): if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
logger.warn("Discarding invalid audio create") logger.warn("Discarding invalid audio create: %s", serializer.errors)
return return
upload = serializer.save() upload = serializer.save()
if is_channel:
return {"object": upload, "target": channel}
else:
return {"object": upload, "target": upload.library} return {"object": upload, "target": upload.library}
...@@ -243,9 +295,10 @@ def inbox_delete_audio(payload, context): ...@@ -243,9 +295,10 @@ def inbox_delete_audio(payload, context):
# we did not receive a list of Ids, so we can probably use the value directly # we did not receive a list of Ids, so we can probably use the value directly
upload_fids = [payload["object"]["id"]] upload_fids = [payload["object"]["id"]]
candidates = music_models.Upload.objects.filter( query = Q(fid__in=upload_fids) & (
library__actor=actor, fid__in=upload_fids Q(library__actor=actor) | Q(track__artist_credit__artist__channel__actor=actor)
) )
candidates = music_models.Upload.objects.filter(query)
total = candidates.count() total = candidates.count()
logger.info("Deleting %s uploads with ids %s", total, upload_fids) logger.info("Deleting %s uploads with ids %s", total, upload_fids)
...@@ -256,6 +309,9 @@ def inbox_delete_audio(payload, context): ...@@ -256,6 +309,9 @@ def inbox_delete_audio(payload, context):
def outbox_delete_audio(context): def outbox_delete_audio(context):
uploads = context["uploads"] uploads = context["uploads"]
library = uploads[0].library library = uploads[0].library
channel = library.get_channel()
actor = channel.actor if channel else library.actor
followers_target = channel.actor if channel else actor
serializer = serializers.ActivitySerializer( serializer = serializers.ActivitySerializer(
{ {
"type": "Delete", "type": "Delete",
...@@ -264,8 +320,611 @@ def outbox_delete_audio(context): ...@@ -264,8 +320,611 @@ def outbox_delete_audio(context):
) )
yield { yield {
"type": "Delete", "type": "Delete",
"actor": library.actor, "actor": actor,
"payload": with_recipients( "payload": with_recipients(
serializer.data, to=[{"type": "followers", "target": library}] serializer.data, to=[{"type": "followers", "target": followers_target}]
),
}
def handle_library_entry_update(payload, context, queryset, serializer_class):
actor = context["actor"]
obj_id = payload["object"].get("id")
if not obj_id:
logger.debug("Discarding update of empty obj")
return
try:
obj = queryset.select_related("attributed_to").get(fid=obj_id)
except queryset.model.DoesNotExist:
logger.debug("Discarding update of unkwnown obj %s", obj_id)
return
if not actor.can_manage(obj):
logger.debug(
"Discarding unauthorize update of obj %s from %s", obj_id, actor.fid
)
return
serializer = serializer_class(obj, data=payload["object"])
if serializer.is_valid():
serializer.save()
else:
logger.debug(
"Discarding update of obj %s because of payload errors: %s",
obj_id,
serializer.errors,
)
@inbox.register({"type": "Update", "object.type": "Track"})
def inbox_update_track(payload, context):
return handle_library_entry_update(
payload,
context,
queryset=music_models.Track.objects.all(),
serializer_class=serializers.TrackSerializer,
)
@inbox.register({"type": "Update", "object.type": "Audio"})
def inbox_update_audio(payload, context):
serializer = serializers.ChannelCreateUploadSerializer(
data=payload, context=context
)
if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
logger.info("Skipped update, invalid payload")
return
serializer.save()
@outbox.register({"type": "Update", "object.type": "Audio"})
def outbox_update_audio(context):
upload = context["upload"]
channel = upload.library.get_channel()
actor = channel.actor
serializer = serializers.ChannelCreateUploadSerializer(
upload, context={"type": "Update", "activity_id_suffix": str(uuid.uuid4())[:8]}
)
yield {
"type": "Update",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
),
}
@inbox.register({"type": "Update", "object.type": "Artist"})
def inbox_update_artist(payload, context):
return handle_library_entry_update(
payload,
context,
queryset=music_models.Artist.objects.all(),
serializer_class=serializers.ArtistSerializer,
)
@inbox.register({"type": "Update", "object.type": "Album"})
def inbox_update_album(payload, context):
return handle_library_entry_update(
payload,
context,
queryset=music_models.Album.objects.all(),
serializer_class=serializers.AlbumSerializer,
)
@outbox.register({"type": "Update", "object.type": "Track"})
def outbox_update_track(context):
track = context["track"]
serializer = serializers.ActivitySerializer(
{"type": "Update", "object": serializers.TrackSerializer(track).data}
)
yield {
"type": "Update",
"actor": actors.get_service_actor(),
"payload": with_recipients(
serializer.data,
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
),
}
@outbox.register({"type": "Update", "object.type": "Album"})
def outbox_update_album(context):
album = context["album"]
serializer = serializers.ActivitySerializer(
{"type": "Update", "object": serializers.AlbumSerializer(album).data}
)
yield {
"type": "Update",
"actor": actors.get_service_actor(),
"payload": with_recipients(
serializer.data,
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
),
}
@outbox.register({"type": "Update", "object.type": "Artist"})
def outbox_update_artist(context):
artist = context["artist"]
serializer = serializers.ActivitySerializer(
{"type": "Update", "object": serializers.ArtistSerializer(artist).data}
)
yield {
"type": "Update",
"actor": actors.get_service_actor(),
"payload": with_recipients(
serializer.data,
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
),
}
@outbox.register(
{
"type": "Delete",
"object.type": [
"Tombstone",
"Actor",
"Person",
"Application",
"Organization",
"Service",
"Group",
],
}
)
def outbox_delete_actor(context):
actor = context["actor"]
serializer = serializers.ActivitySerializer(
{"type": "Delete", "object": {"type": actor.type, "id": actor.fid}}
)
yield {
"type": "Delete",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
),
}
@inbox.register(
{
"type": "Delete",
"object.type": [
"Actor",
"Person",
"Application",
"Organization",
"Service",
"Group",
],
}
)
def inbox_delete_actor(payload, context):
actor = context["actor"]
serializer = serializers.ActorDeleteSerializer(data=payload)
if not serializer.is_valid():
logger.info("Skipped actor %s deletion, invalid payload", actor.fid)
return
deleted_fid = serializer.validated_data["fid"]
try:
# ensure the actor only can delete itself, and is a remote one
actor = models.Actor.objects.local(False).get(fid=deleted_fid, pk=actor.pk)
except models.Actor.DoesNotExist:
logger.warn("Cannot delete actor %s, no matching object found", actor.fid)
return
actor.delete()
@inbox.register({"type": "Delete", "object.type": "Tombstone"})
def inbox_delete(payload, context):
serializer = serializers.DeleteSerializer(data=payload, context=context)
if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
logger.info("Skipped deletion, invalid payload")
return
to_delete = serializer.validated_data["object"]
to_delete.delete()
@inbox.register({"type": "Flag"})
def inbox_flag(payload, context):
serializer = serializers.FlagSerializer(data=payload, context=context)
if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
logger.debug(
"Discarding invalid report from {}: %s",
context["actor"].fid,
serializer.errors,
)
return
report = serializer.save()
return {"object": report.target, "related_object": report}
@outbox.register({"type": "Flag"})
def outbox_flag(context):
report = context["report"]
if not report.target or not report.target.fid:
return
actor = actors.get_service_actor()
serializer = serializers.FlagSerializer(report)
yield {
"type": "Flag",
"actor": actor,
"payload": with_recipients(
serializer.data,
# Mastodon requires the report to be sent to the reported actor inbox
# (and not the shared inbox)
to=[{"type": "actor_inbox", "actor": report.target_owner}],
),
}
@inbox.register({"type": "Delete", "object.type": "Album"})
def inbox_delete_album(payload, context):
actor = context["actor"]
album_id = payload["object"].get("id")
if not album_id:
logger.debug("Discarding deletion of empty library")
return
query = Q(fid=album_id) & (
Q(attributed_to=actor) | Q(artist_credit__artist__channel__actor=actor)
)
try:
album = music_models.Album.objects.get(query)
except music_models.Album.DoesNotExist:
logger.debug("Discarding deletion of unkwnown album %s", album_id)
return
album.delete()
@outbox.register({"type": "Delete", "object.type": "Album"})
def outbox_delete_album(context):
album = context["album"]
album_artist = album.artist_credit.all()[0].artist
actor = (
album_artist.channel.actor
if album_artist.get_channel()
else album.attributed_to
)
actor = actor or actors.get_service_actor()
serializer = serializers.ActivitySerializer(
{"type": "Delete", "object": {"type": "Album", "id": album.fid}}
)
yield {
"type": "Delete",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
),
}
@outbox.register({"type": "Like", "object.type": "Track"})
def outbox_create_track_favorite(context):
track = context["track"]
actor = context["actor"]
serializer = serializers.ActivitySerializer(
{
"type": "Like",
"id": context["id"],
"object": {"type": "Track", "id": track.fid},
"audience": actor.user.privacy_level,
}
)
yield {
"type": "Like",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
}
@outbox.register({"type": "Dislike", "object.type": "Track"})
def outbox_delete_favorite(context):
favorite = context["favorite"]
actor = favorite.actor
serializer = serializers.ActivitySerializer(
{"type": "Dislike", "object": {"type": "Track", "id": favorite.track.fid}}
)
yield {
"type": "Dislike",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
}
@inbox.register({"type": "Like", "object.type": "Track"})
def inbox_create_favorite(payload, context):
serializer = serializers.TrackFavoriteSerializer(data=payload)
serializer.is_valid(raise_exception=True)
instance = serializer.save()
return {"object": instance}
@inbox.register({"type": "Dislike", "object.type": "Track"})
def inbox_delete_favorite(payload, context):
actor = context["actor"]
track_id = payload["object"].get("id")
query = Q(track__fid=track_id) & Q(actor=actor)
try:
favorite = favorites_models.TrackFavorite.objects.get(query)
except favorites_models.TrackFavorite.DoesNotExist:
logger.debug(
"Discarding deletion of unkwnown favorite with track : %s", track_id
)
return
favorite.delete()
@outbox.register({"type": "Listen", "object.type": "Track"})
def outbox_create_listening(context):
track = context["track"]
actor = context["actor"]
serializer = serializers.ActivitySerializer(
{
"type": "Listen",
"id": context["id"],
"object": {"type": "Track", "id": track.fid},
"audience": actor.user.privacy_level,
}
)
yield {
"type": "Listen",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
}
@outbox.register({"type": "Delete", "object.type": "Listen"})
def outbox_delete_listening(context):
listening = context["listening"]
actor = listening.actor
serializer = serializers.ActivitySerializer(
{"type": "Delete", "object": {"type": "Listen", "id": listening.fid}}
)
yield {
"type": "Delete",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
}
@inbox.register({"type": "Listen", "object.type": "Track"})
def inbox_create_listening(payload, context):
serializer = serializers.ListeningSerializer(data=payload)
serializer.is_valid(raise_exception=True)
instance = serializer.save()
return {"object": instance}
@inbox.register({"type": "Delete", "object.type": "Listen"})
def inbox_delete_listening(payload, context):
actor = context["actor"]
listening_id = payload["object"].get("id")
query = Q(fid=listening_id) & Q(actor=actor)
try:
favorite = history_models.Listening.objects.get(query)
except history_models.Listening.DoesNotExist:
logger.debug("Discarding deletion of unkwnown listening %s", listening_id)
return
favorite.delete()
@outbox.register({"type": "Update", "object.type": "Person"})
def outbox_update_actor(context):
actor = context["actor"]
# this is a bit hacky, but we want to send the privacy level of the user as audience
# to delete or fetch all user activities inheriting user.privacy_level
setattr(actor, "audience", actor.user.privacy_level)
serializer = serializers.ActivitySerializer(
{"type": "Update", "object": serializers.ActorSerializer(actor).data}
)
yield {
"type": "Update",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
),
}
@inbox.register({"type": "Update", "object.type": "Person"})
def inbox_update_actor(payload, context):
actor = context["actor"]
serializer = serializers.ActorSerializer(data=payload["object"])
if serializer.is_valid() and "audience" in serializer.validated_data:
privacy_level = serializer.validated_data["audience"]
if privacy_level in [
"me",
"instance",
]:
# we delete all activities inheriting user.privacy_level
history_models.Listening.objects.filter(actor=actor).delete()
favorites_models.TrackFavorite.objects.filter(actor=actor).delete()
@outbox.register({"type": "Create", "object.type": "Playlist"})
def outbox_create_playlist(context):
playlist = context["playlist"]
serializer = serializers.ActivitySerializer(
{
"type": "Create",
"actor": playlist.actor,
"id": playlist.fid,
"object": serializers.PlaylistSerializer(playlist).data,
}
)
yield {
"type": "Create",
"actor": playlist.actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": playlist.actor}],
),
}
@outbox.register({"type": "Delete", "object.type": "Playlist"})
def outbox_delete_playlist(context):
playlist = context["playlist"]
actor = playlist.actor
serializer = serializers.ActivitySerializer(
{"type": "Delete", "object": {"type": "Playlist", "id": playlist.fid}}
)
yield {
"type": "Delete",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
),
}
@inbox.register({"type": "Create", "object.type": "Playlist"})
def inbox_create_playlist(payload, context):
serializer = serializers.PlaylistSerializer(data=payload["object"])
serializer.is_valid(raise_exception=True)
instance = serializer.save()
return {"object": instance}
@inbox.register({"type": "Delete", "object.type": "Playlist"})
def inbox_delete_playlist(payload, context):
actor = context["actor"]
playlist_id = payload["object"].get("id")
query = Q(fid=playlist_id) & Q(actor=actor)
try:
playlist = playlist_models.Playlist.objects.get(query)
except playlist_models.Playlist.DoesNotExist:
logger.debug("Discarding deletion of unkwnown listening %s", playlist_id)
return
playlist.playlist_tracks.all().delete()
playlist.delete()
@inbox.register({"type": "Update", "object.type": "Playlist"})
def inbox_update_playlist(payload, context):
"""If we receive an update on an unkwnown playlist, we create the playlist"""
playlist_id = payload["object"].get("id")
serializer = serializers.PlaylistSerializer(data=payload["object"])
if serializer.is_valid(raise_exception=True):
playlist = serializer.save()
# we update the playlist.library to get the plt.track.uploads locally
if follows := playlist.library.received_follows.filter(approved=True):
playlist.library.schedule_scan(follows[0].actor, force=True)
# we trigger a scan since we use this activity to avoid sending many PlaylistTracks activities
playlist.schedule_scan(actors.get_service_actor(), force=True)
return
else:
logger.debug(
"Discarding update of playlist_id %s because of payload errors: %s",
playlist_id,
serializer.errors,
)
@outbox.register({"type": "Update", "object.type": "Playlist"})
def outbox_update_playlist(context):
playlist = context["playlist"]
serializer = serializers.ActivitySerializer(
{"type": "Update", "object": serializers.PlaylistSerializer(playlist).data}
)
yield {
"type": "Update",
"actor": playlist.actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": playlist.actor}],
),
}
@inbox.register({"type": "Update", "object.type": "AudioCollection"})
def inbox_update_audiocollection(payload, context):
serializer = serializers.AudioCollectionSerializer(
data=payload["object"], context=context
)
serializer.is_valid(raise_exception=True)
serializer.save()
@outbox.register({"type": "Update", "object.type": "AudioCollection"})
def outbox_update_audiocollection(context):
audios = context["audios"]
serializer = serializers.ActivitySerializer(
{"type": "Update", "object": serializers.AudioCollectionSerializer(audios).data}
)
yield {
"type": "Update",
"actor": audios[0].library.actor,
"payload": with_recipients(
serializer.data,
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
),
}
@inbox.register({"type": "Delete", "object.type": "AudioCollection"})
def inbox_delete_audiocollection(payload, context):
serializer = serializers.AudioCollectionSerializer(
data=payload["object"], context=context
)
serializer.is_valid(raise_exception=True)
for upload in serializer.validated_data["items"]:
music_models.Upload.objects.filter(fid=upload["id"]).delete()
@outbox.register({"type": "Delete", "object.type": "AudioCollection"})
def outbox_delete_audiocollection(context):
audios = context["audios"]
serializer = serializers.ActivitySerializer(
{"type": "Delete", "object": serializers.AudioCollectionSerializer(audios).data}
)
yield {
"type": "Delete",
"actor": audios[0].library.actor,
"payload": with_recipients(
serializer.data,
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
), ),
} }
CONTEXT = {
"type": "@type",
"id": "@id",
"HTML": {"@id": "rdf:HTML"},
"@vocab": "http://schema.org/",
"xml": "http://www.w3.org/XML/1998/namespace",
"foaf": "http://xmlns.com/foaf/0.1/",
"eli": "http://data.europa.eu/eli/ontology#",
"snomed": "http://purl.bioontology.org/ontology/SNOMEDCT/",
"bibo": "http://purl.org/ontology/bibo/",
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
"skos": "http://www.w3.org/2004/02/skos/core#",
"void": "http://rdfs.org/ns/void#",
"dc": "http://purl.org/dc/elements/1.1/",
"dctype": "http://purl.org/dc/dcmitype/",
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
"dcat": "http://www.w3.org/ns/dcat#",
"rdfa": "http://www.w3.org/ns/rdfa#",
"xsd": "http://www.w3.org/2001/XMLSchema#",
"schema": "http://schema.org/",
"dct": "http://purl.org/dc/terms/",
"dcterms": "http://purl.org/dc/terms/",
"owl": "http://www.w3.org/2002/07/owl#",
"3DModel": {"@id": "schema:3DModel"},
"AMRadioChannel": {"@id": "schema:AMRadioChannel"},
"APIReference": {"@id": "schema:APIReference"},
"Abdomen": {"@id": "schema:Abdomen"},
"AboutPage": {"@id": "schema:AboutPage"},
"AcceptAction": {"@id": "schema:AcceptAction"},
"Accommodation": {"@id": "schema:Accommodation"},
"AccountingService": {"@id": "schema:AccountingService"},
"AchieveAction": {"@id": "schema:AchieveAction"},
"Action": {"@id": "schema:Action"},
"ActionAccessSpecification": {"@id": "schema:ActionAccessSpecification"},
"ActionStatusType": {"@id": "schema:ActionStatusType"},
"ActivateAction": {"@id": "schema:ActivateAction"},
"ActiveActionStatus": {"@id": "schema:ActiveActionStatus"},
"ActiveNotRecruiting": {"@id": "schema:ActiveNotRecruiting"},
"AddAction": {"@id": "schema:AddAction"},
"AdministrativeArea": {"@id": "schema:AdministrativeArea"},
"AdultEntertainment": {"@id": "schema:AdultEntertainment"},
"AdvertiserContentArticle": {"@id": "schema:AdvertiserContentArticle"},
"AerobicActivity": {"@id": "schema:AerobicActivity"},
"AggregateOffer": {"@id": "schema:AggregateOffer"},
"AggregateRating": {"@id": "schema:AggregateRating"},
"AgreeAction": {"@id": "schema:AgreeAction"},
"Airline": {"@id": "schema:Airline"},
"Airport": {"@id": "schema:Airport"},
"AlbumRelease": {"@id": "schema:AlbumRelease"},
"AlignmentObject": {"@id": "schema:AlignmentObject"},
"AllWheelDriveConfiguration": {"@id": "schema:AllWheelDriveConfiguration"},
"AllocateAction": {"@id": "schema:AllocateAction"},
"AmusementPark": {"@id": "schema:AmusementPark"},
"AnaerobicActivity": {"@id": "schema:AnaerobicActivity"},
"AnalysisNewsArticle": {"@id": "schema:AnalysisNewsArticle"},
"AnatomicalStructure": {"@id": "schema:AnatomicalStructure"},
"AnatomicalSystem": {"@id": "schema:AnatomicalSystem"},
"Anesthesia": {"@id": "schema:Anesthesia"},
"AnimalShelter": {"@id": "schema:AnimalShelter"},
"Answer": {"@id": "schema:Answer"},
"Apartment": {"@id": "schema:Apartment"},
"ApartmentComplex": {"@id": "schema:ApartmentComplex"},
"Appearance": {"@id": "schema:Appearance"},
"AppendAction": {"@id": "schema:AppendAction"},
"ApplyAction": {"@id": "schema:ApplyAction"},
"ApprovedIndication": {"@id": "schema:ApprovedIndication"},
"Aquarium": {"@id": "schema:Aquarium"},
"ArchiveComponent": {"@id": "schema:ArchiveComponent"},
"ArchiveOrganization": {"@id": "schema:ArchiveOrganization"},
"ArriveAction": {"@id": "schema:ArriveAction"},
"ArtGallery": {"@id": "schema:ArtGallery"},
"Artery": {"@id": "schema:Artery"},
"Article": {"@id": "schema:Article"},
"AskAction": {"@id": "schema:AskAction"},
"AskPublicNewsArticle": {"@id": "schema:AskPublicNewsArticle"},
"AssessAction": {"@id": "schema:AssessAction"},
"AssignAction": {"@id": "schema:AssignAction"},
"Atlas": {"@id": "schema:Atlas"},
"Attorney": {"@id": "schema:Attorney"},
"Audience": {"@id": "schema:Audience"},
"AudioObject": {"@id": "schema:AudioObject"},
"Audiobook": {"@id": "schema:Audiobook"},
"AudiobookFormat": {"@id": "schema:AudiobookFormat"},
"AuthenticContent": {"@id": "schema:AuthenticContent"},
"AuthoritativeLegalValue": {"@id": "schema:AuthoritativeLegalValue"},
"AuthorizeAction": {"@id": "schema:AuthorizeAction"},
"AutoBodyShop": {"@id": "schema:AutoBodyShop"},
"AutoDealer": {"@id": "schema:AutoDealer"},
"AutoPartsStore": {"@id": "schema:AutoPartsStore"},
"AutoRental": {"@id": "schema:AutoRental"},
"AutoRepair": {"@id": "schema:AutoRepair"},
"AutoWash": {"@id": "schema:AutoWash"},
"AutomatedTeller": {"@id": "schema:AutomatedTeller"},
"AutomotiveBusiness": {"@id": "schema:AutomotiveBusiness"},
"Ayurvedic": {"@id": "schema:Ayurvedic"},
"BackgroundNewsArticle": {"@id": "schema:BackgroundNewsArticle"},
"Bacteria": {"@id": "schema:Bacteria"},
"Bakery": {"@id": "schema:Bakery"},
"Balance": {"@id": "schema:Balance"},
"BankAccount": {"@id": "schema:BankAccount"},
"BankOrCreditUnion": {"@id": "schema:BankOrCreditUnion"},
"BarOrPub": {"@id": "schema:BarOrPub"},
"Barcode": {"@id": "schema:Barcode"},
"Beach": {"@id": "schema:Beach"},
"BeautySalon": {"@id": "schema:BeautySalon"},
"BedAndBreakfast": {"@id": "schema:BedAndBreakfast"},
"BedDetails": {"@id": "schema:BedDetails"},
"BedType": {"@id": "schema:BedType"},
"BefriendAction": {"@id": "schema:BefriendAction"},
"BenefitsHealthAspect": {"@id": "schema:BenefitsHealthAspect"},
"BikeStore": {"@id": "schema:BikeStore"},
"Blog": {"@id": "schema:Blog"},
"BlogPosting": {"@id": "schema:BlogPosting"},
"BloodTest": {"@id": "schema:BloodTest"},
"BoardingPolicyType": {"@id": "schema:BoardingPolicyType"},
"BodyOfWater": {"@id": "schema:BodyOfWater"},
"Bone": {"@id": "schema:Bone"},
"Book": {"@id": "schema:Book"},
"BookFormatType": {"@id": "schema:BookFormatType"},
"BookSeries": {"@id": "schema:BookSeries"},
"BookStore": {"@id": "schema:BookStore"},
"BookmarkAction": {"@id": "schema:BookmarkAction"},
"Boolean": {"@id": "schema:Boolean"},
"BorrowAction": {"@id": "schema:BorrowAction"},
"BowlingAlley": {"@id": "schema:BowlingAlley"},
"BrainStructure": {"@id": "schema:BrainStructure"},
"Brand": {"@id": "schema:Brand"},
"BreadcrumbList": {"@id": "schema:BreadcrumbList"},
"Brewery": {"@id": "schema:Brewery"},
"Bridge": {"@id": "schema:Bridge"},
"BroadcastChannel": {"@id": "schema:BroadcastChannel"},
"BroadcastEvent": {"@id": "schema:BroadcastEvent"},
"BroadcastFrequencySpecification": {
"@id": "schema:BroadcastFrequencySpecification"
},
"BroadcastRelease": {"@id": "schema:BroadcastRelease"},
"BroadcastService": {"@id": "schema:BroadcastService"},
"BrokerageAccount": {"@id": "schema:BrokerageAccount"},
"BuddhistTemple": {"@id": "schema:BuddhistTemple"},
"BusOrCoach": {"@id": "schema:BusOrCoach"},
"BusReservation": {"@id": "schema:BusReservation"},
"BusStation": {"@id": "schema:BusStation"},
"BusStop": {"@id": "schema:BusStop"},
"BusTrip": {"@id": "schema:BusTrip"},
"BusinessAudience": {"@id": "schema:BusinessAudience"},
"BusinessEntityType": {"@id": "schema:BusinessEntityType"},
"BusinessEvent": {"@id": "schema:BusinessEvent"},
"BusinessFunction": {"@id": "schema:BusinessFunction"},
"BuyAction": {"@id": "schema:BuyAction"},
"CDFormat": {"@id": "schema:CDFormat"},
"CT": {"@id": "schema:CT"},
"CableOrSatelliteService": {"@id": "schema:CableOrSatelliteService"},
"CafeOrCoffeeShop": {"@id": "schema:CafeOrCoffeeShop"},
"Campground": {"@id": "schema:Campground"},
"CampingPitch": {"@id": "schema:CampingPitch"},
"Canal": {"@id": "schema:Canal"},
"CancelAction": {"@id": "schema:CancelAction"},
"Car": {"@id": "schema:Car"},
"CarUsageType": {"@id": "schema:CarUsageType"},
"Cardiovascular": {"@id": "schema:Cardiovascular"},
"CardiovascularExam": {"@id": "schema:CardiovascularExam"},
"CaseSeries": {"@id": "schema:CaseSeries"},
"Casino": {"@id": "schema:Casino"},
"CassetteFormat": {"@id": "schema:CassetteFormat"},
"CategoryCode": {"@id": "schema:CategoryCode"},
"CategoryCodeSet": {"@id": "schema:CategoryCodeSet"},
"CatholicChurch": {"@id": "schema:CatholicChurch"},
"CausesHealthAspect": {"@id": "schema:CausesHealthAspect"},
"Cemetery": {"@id": "schema:Cemetery"},
"Chapter": {"@id": "schema:Chapter"},
"CheckAction": {"@id": "schema:CheckAction"},
"CheckInAction": {"@id": "schema:CheckInAction"},
"CheckOutAction": {"@id": "schema:CheckOutAction"},
"CheckoutPage": {"@id": "schema:CheckoutPage"},
"ChildCare": {"@id": "schema:ChildCare"},
"ChildrensEvent": {"@id": "schema:ChildrensEvent"},
"Chiropractic": {"@id": "schema:Chiropractic"},
"ChooseAction": {"@id": "schema:ChooseAction"},
"Church": {"@id": "schema:Church"},
"City": {"@id": "schema:City"},
"CityHall": {"@id": "schema:CityHall"},
"CivicStructure": {"@id": "schema:CivicStructure"},
"Claim": {"@id": "schema:Claim"},
"ClaimReview": {"@id": "schema:ClaimReview"},
"Class": {"@id": "schema:Class"},
"Clinician": {"@id": "schema:Clinician"},
"Clip": {"@id": "schema:Clip"},
"ClothingStore": {"@id": "schema:ClothingStore"},
"CoOp": {"@id": "schema:CoOp"},
"Code": {"@id": "schema:Code"},
"CohortStudy": {"@id": "schema:CohortStudy"},
"Collection": {"@id": "schema:Collection"},
"CollectionPage": {"@id": "schema:CollectionPage"},
"CollegeOrUniversity": {"@id": "schema:CollegeOrUniversity"},
"ComedyClub": {"@id": "schema:ComedyClub"},
"ComedyEvent": {"@id": "schema:ComedyEvent"},
"ComicCoverArt": {"@id": "schema:ComicCoverArt"},
"ComicIssue": {"@id": "schema:ComicIssue"},
"ComicSeries": {"@id": "schema:ComicSeries"},
"ComicStory": {"@id": "schema:ComicStory"},
"Comment": {"@id": "schema:Comment"},
"CommentAction": {"@id": "schema:CommentAction"},
"CommentPermission": {"@id": "schema:CommentPermission"},
"CommunicateAction": {"@id": "schema:CommunicateAction"},
"CommunityHealth": {"@id": "schema:CommunityHealth"},
"CompilationAlbum": {"@id": "schema:CompilationAlbum"},
"CompleteDataFeed": {"@id": "schema:CompleteDataFeed"},
"Completed": {"@id": "schema:Completed"},
"CompletedActionStatus": {"@id": "schema:CompletedActionStatus"},
"CompoundPriceSpecification": {"@id": "schema:CompoundPriceSpecification"},
"ComputerLanguage": {"@id": "schema:ComputerLanguage"},
"ComputerStore": {"@id": "schema:ComputerStore"},
"ConfirmAction": {"@id": "schema:ConfirmAction"},
"Consortium": {"@id": "schema:Consortium"},
"ConsumeAction": {"@id": "schema:ConsumeAction"},
"ContactPage": {"@id": "schema:ContactPage"},
"ContactPoint": {"@id": "schema:ContactPoint"},
"ContactPointOption": {"@id": "schema:ContactPointOption"},
"ContagiousnessHealthAspect": {"@id": "schema:ContagiousnessHealthAspect"},
"Continent": {"@id": "schema:Continent"},
"ControlAction": {"@id": "schema:ControlAction"},
"ConvenienceStore": {"@id": "schema:ConvenienceStore"},
"Conversation": {"@id": "schema:Conversation"},
"CookAction": {"@id": "schema:CookAction"},
"Corporation": {"@id": "schema:Corporation"},
"CorrectionComment": {"@id": "schema:CorrectionComment"},
"Country": {"@id": "schema:Country"},
"Course": {"@id": "schema:Course"},
"CourseInstance": {"@id": "schema:CourseInstance"},
"Courthouse": {"@id": "schema:Courthouse"},
"CoverArt": {"@id": "schema:CoverArt"},
"CovidTestingFacility": {"@id": "schema:CovidTestingFacility"},
"CreateAction": {"@id": "schema:CreateAction"},
"CreativeWork": {"@id": "schema:CreativeWork"},
"CreativeWorkSeason": {"@id": "schema:CreativeWorkSeason"},
"CreativeWorkSeries": {"@id": "schema:CreativeWorkSeries"},
"CreditCard": {"@id": "schema:CreditCard"},
"Crematorium": {"@id": "schema:Crematorium"},
"CriticReview": {"@id": "schema:CriticReview"},
"CrossSectional": {"@id": "schema:CrossSectional"},
"CssSelectorType": {"@id": "schema:CssSelectorType"},
"CurrencyConversionService": {"@id": "schema:CurrencyConversionService"},
"DDxElement": {"@id": "schema:DDxElement"},
"DJMixAlbum": {"@id": "schema:DJMixAlbum"},
"DVDFormat": {"@id": "schema:DVDFormat"},
"DamagedCondition": {"@id": "schema:DamagedCondition"},
"DanceEvent": {"@id": "schema:DanceEvent"},
"DanceGroup": {"@id": "schema:DanceGroup"},
"DataCatalog": {"@id": "schema:DataCatalog"},
"DataDownload": {"@id": "schema:DataDownload"},
"DataFeed": {"@id": "schema:DataFeed"},
"DataFeedItem": {"@id": "schema:DataFeedItem"},
"DataType": {"@id": "schema:DataType"},
"Dataset": {"@id": "schema:Dataset"},
"Date": {"@id": "schema:Date"},
"DateTime": {"@id": "schema:DateTime"},
"DatedMoneySpecification": {"@id": "schema:DatedMoneySpecification"},
"DayOfWeek": {"@id": "schema:DayOfWeek"},
"DaySpa": {"@id": "schema:DaySpa"},
"DeactivateAction": {"@id": "schema:DeactivateAction"},
"DefenceEstablishment": {"@id": "schema:DefenceEstablishment"},
"DefinedTerm": {"@id": "schema:DefinedTerm"},
"DefinedTermSet": {"@id": "schema:DefinedTermSet"},
"DefinitiveLegalValue": {"@id": "schema:DefinitiveLegalValue"},
"DeleteAction": {"@id": "schema:DeleteAction"},
"DeliveryChargeSpecification": {"@id": "schema:DeliveryChargeSpecification"},
"DeliveryEvent": {"@id": "schema:DeliveryEvent"},
"DeliveryMethod": {"@id": "schema:DeliveryMethod"},
"Demand": {"@id": "schema:Demand"},
"DemoAlbum": {"@id": "schema:DemoAlbum"},
"Dentist": {"@id": "schema:Dentist"},
"Dentistry": {"@id": "schema:Dentistry"},
"DepartAction": {"@id": "schema:DepartAction"},
"DepartmentStore": {"@id": "schema:DepartmentStore"},
"DepositAccount": {"@id": "schema:DepositAccount"},
"Dermatologic": {"@id": "schema:Dermatologic"},
"Dermatology": {"@id": "schema:Dermatology"},
"DiabeticDiet": {"@id": "schema:DiabeticDiet"},
"Diagnostic": {"@id": "schema:Diagnostic"},
"DiagnosticLab": {"@id": "schema:DiagnosticLab"},
"DiagnosticProcedure": {"@id": "schema:DiagnosticProcedure"},
"Diet": {"@id": "schema:Diet"},
"DietNutrition": {"@id": "schema:DietNutrition"},
"DietarySupplement": {"@id": "schema:DietarySupplement"},
"DigitalAudioTapeFormat": {"@id": "schema:DigitalAudioTapeFormat"},
"DigitalDocument": {"@id": "schema:DigitalDocument"},
"DigitalDocumentPermission": {"@id": "schema:DigitalDocumentPermission"},
"DigitalDocumentPermissionType": {"@id": "schema:DigitalDocumentPermissionType"},
"DigitalFormat": {"@id": "schema:DigitalFormat"},
"DisagreeAction": {"@id": "schema:DisagreeAction"},
"Discontinued": {"@id": "schema:Discontinued"},
"DiscoverAction": {"@id": "schema:DiscoverAction"},
"DiscussionForumPosting": {"@id": "schema:DiscussionForumPosting"},
"DislikeAction": {"@id": "schema:DislikeAction"},
"Distance": {"@id": "schema:Distance"},
"Distillery": {"@id": "schema:Distillery"},
"DonateAction": {"@id": "schema:DonateAction"},
"DoseSchedule": {"@id": "schema:DoseSchedule"},
"DoubleBlindedTrial": {"@id": "schema:DoubleBlindedTrial"},
"DownloadAction": {"@id": "schema:DownloadAction"},
"DrawAction": {"@id": "schema:DrawAction"},
"Drawing": {"@id": "schema:Drawing"},
"DrinkAction": {"@id": "schema:DrinkAction"},
"DriveWheelConfigurationValue": {"@id": "schema:DriveWheelConfigurationValue"},
"DrivingSchoolVehicleUsage": {"@id": "schema:DrivingSchoolVehicleUsage"},
"Drug": {"@id": "schema:Drug"},
"DrugClass": {"@id": "schema:DrugClass"},
"DrugCost": {"@id": "schema:DrugCost"},
"DrugCostCategory": {"@id": "schema:DrugCostCategory"},
"DrugLegalStatus": {"@id": "schema:DrugLegalStatus"},
"DrugPregnancyCategory": {"@id": "schema:DrugPregnancyCategory"},
"DrugPrescriptionStatus": {"@id": "schema:DrugPrescriptionStatus"},
"DrugStrength": {"@id": "schema:DrugStrength"},
"DryCleaningOrLaundry": {"@id": "schema:DryCleaningOrLaundry"},
"Duration": {"@id": "schema:Duration"},
"EBook": {"@id": "schema:EBook"},
"EPRelease": {"@id": "schema:EPRelease"},
"Ear": {"@id": "schema:Ear"},
"EatAction": {"@id": "schema:EatAction"},
"EducationEvent": {"@id": "schema:EducationEvent"},
"EducationalAudience": {"@id": "schema:EducationalAudience"},
"EducationalOccupationalCredential": {
"@id": "schema:EducationalOccupationalCredential"
},
"EducationalOccupationalProgram": {"@id": "schema:EducationalOccupationalProgram"},
"EducationalOrganization": {"@id": "schema:EducationalOrganization"},
"Electrician": {"@id": "schema:Electrician"},
"ElectronicsStore": {"@id": "schema:ElectronicsStore"},
"ElementarySchool": {"@id": "schema:ElementarySchool"},
"EmailMessage": {"@id": "schema:EmailMessage"},
"Embassy": {"@id": "schema:Embassy"},
"Emergency": {"@id": "schema:Emergency"},
"EmergencyService": {"@id": "schema:EmergencyService"},
"EmployeeRole": {"@id": "schema:EmployeeRole"},
"EmployerAggregateRating": {"@id": "schema:EmployerAggregateRating"},
"EmployerReview": {"@id": "schema:EmployerReview"},
"EmploymentAgency": {"@id": "schema:EmploymentAgency"},
"Endocrine": {"@id": "schema:Endocrine"},
"EndorseAction": {"@id": "schema:EndorseAction"},
"EndorsementRating": {"@id": "schema:EndorsementRating"},
"Energy": {"@id": "schema:Energy"},
"EngineSpecification": {"@id": "schema:EngineSpecification"},
"EnrollingByInvitation": {"@id": "schema:EnrollingByInvitation"},
"EntertainmentBusiness": {"@id": "schema:EntertainmentBusiness"},
"EntryPoint": {"@id": "schema:EntryPoint"},
"Enumeration": {"@id": "schema:Enumeration"},
"Episode": {"@id": "schema:Episode"},
"Event": {"@id": "schema:Event"},
"EventAttendanceModeEnumeration": {"@id": "schema:EventAttendanceModeEnumeration"},
"EventCancelled": {"@id": "schema:EventCancelled"},
"EventMovedOnline": {"@id": "schema:EventMovedOnline"},
"EventPostponed": {"@id": "schema:EventPostponed"},
"EventRescheduled": {"@id": "schema:EventRescheduled"},
"EventReservation": {"@id": "schema:EventReservation"},
"EventScheduled": {"@id": "schema:EventScheduled"},
"EventSeries": {"@id": "schema:EventSeries"},
"EventStatusType": {"@id": "schema:EventStatusType"},
"EventVenue": {"@id": "schema:EventVenue"},
"EvidenceLevelA": {"@id": "schema:EvidenceLevelA"},
"EvidenceLevelB": {"@id": "schema:EvidenceLevelB"},
"EvidenceLevelC": {"@id": "schema:EvidenceLevelC"},
"ExchangeRateSpecification": {"@id": "schema:ExchangeRateSpecification"},
"ExchangeRefund": {"@id": "schema:ExchangeRefund"},
"ExerciseAction": {"@id": "schema:ExerciseAction"},
"ExerciseGym": {"@id": "schema:ExerciseGym"},
"ExercisePlan": {"@id": "schema:ExercisePlan"},
"ExhibitionEvent": {"@id": "schema:ExhibitionEvent"},
"Eye": {"@id": "schema:Eye"},
"FAQPage": {"@id": "schema:FAQPage"},
"FDAcategoryA": {"@id": "schema:FDAcategoryA"},
"FDAcategoryB": {"@id": "schema:FDAcategoryB"},
"FDAcategoryC": {"@id": "schema:FDAcategoryC"},
"FDAcategoryD": {"@id": "schema:FDAcategoryD"},
"FDAcategoryX": {"@id": "schema:FDAcategoryX"},
"FDAnotEvaluated": {"@id": "schema:FDAnotEvaluated"},
"FMRadioChannel": {"@id": "schema:FMRadioChannel"},
"FailedActionStatus": {"@id": "schema:FailedActionStatus"},
"False": {"@id": "schema:False"},
"FastFoodRestaurant": {"@id": "schema:FastFoodRestaurant"},
"Female": {"@id": "schema:Female"},
"Festival": {"@id": "schema:Festival"},
"FilmAction": {"@id": "schema:FilmAction"},
"FinancialProduct": {"@id": "schema:FinancialProduct"},
"FinancialService": {"@id": "schema:FinancialService"},
"FindAction": {"@id": "schema:FindAction"},
"FireStation": {"@id": "schema:FireStation"},
"Flexibility": {"@id": "schema:Flexibility"},
"Flight": {"@id": "schema:Flight"},
"FlightReservation": {"@id": "schema:FlightReservation"},
"Float": {"@id": "schema:Float"},
"FloorPlan": {"@id": "schema:FloorPlan"},
"Florist": {"@id": "schema:Florist"},
"FollowAction": {"@id": "schema:FollowAction"},
"FoodEstablishment": {"@id": "schema:FoodEstablishment"},
"FoodEstablishmentReservation": {"@id": "schema:FoodEstablishmentReservation"},
"FoodEvent": {"@id": "schema:FoodEvent"},
"FoodService": {"@id": "schema:FoodService"},
"FourWheelDriveConfiguration": {"@id": "schema:FourWheelDriveConfiguration"},
"Friday": {"@id": "schema:Friday"},
"FrontWheelDriveConfiguration": {"@id": "schema:FrontWheelDriveConfiguration"},
"FullRefund": {"@id": "schema:FullRefund"},
"FundingAgency": {"@id": "schema:FundingAgency"},
"FundingScheme": {"@id": "schema:FundingScheme"},
"Fungus": {"@id": "schema:Fungus"},
"FurnitureStore": {"@id": "schema:FurnitureStore"},
"Game": {"@id": "schema:Game"},
"GamePlayMode": {"@id": "schema:GamePlayMode"},
"GameServer": {"@id": "schema:GameServer"},
"GameServerStatus": {"@id": "schema:GameServerStatus"},
"GardenStore": {"@id": "schema:GardenStore"},
"GasStation": {"@id": "schema:GasStation"},
"Gastroenterologic": {"@id": "schema:Gastroenterologic"},
"GatedResidenceCommunity": {"@id": "schema:GatedResidenceCommunity"},
"GenderType": {"@id": "schema:GenderType"},
"GeneralContractor": {"@id": "schema:GeneralContractor"},
"Genetic": {"@id": "schema:Genetic"},
"Genitourinary": {"@id": "schema:Genitourinary"},
"GeoCircle": {"@id": "schema:GeoCircle"},
"GeoCoordinates": {"@id": "schema:GeoCoordinates"},
"GeoShape": {"@id": "schema:GeoShape"},
"GeospatialGeometry": {"@id": "schema:GeospatialGeometry"},
"Geriatric": {"@id": "schema:Geriatric"},
"GiveAction": {"@id": "schema:GiveAction"},
"GlutenFreeDiet": {"@id": "schema:GlutenFreeDiet"},
"GolfCourse": {"@id": "schema:GolfCourse"},
"GovernmentBuilding": {"@id": "schema:GovernmentBuilding"},
"GovernmentOffice": {"@id": "schema:GovernmentOffice"},
"GovernmentOrganization": {"@id": "schema:GovernmentOrganization"},
"GovernmentPermit": {"@id": "schema:GovernmentPermit"},
"GovernmentService": {"@id": "schema:GovernmentService"},
"Grant": {"@id": "schema:Grant"},
"GraphicNovel": {"@id": "schema:GraphicNovel"},
"GroceryStore": {"@id": "schema:GroceryStore"},
"GroupBoardingPolicy": {"@id": "schema:GroupBoardingPolicy"},
"Guide": {"@id": "schema:Guide"},
"Gynecologic": {"@id": "schema:Gynecologic"},
"HVACBusiness": {"@id": "schema:HVACBusiness"},
"HairSalon": {"@id": "schema:HairSalon"},
"HalalDiet": {"@id": "schema:HalalDiet"},
"Hardcover": {"@id": "schema:Hardcover"},
"HardwareStore": {"@id": "schema:HardwareStore"},
"Head": {"@id": "schema:Head"},
"HealthAndBeautyBusiness": {"@id": "schema:HealthAndBeautyBusiness"},
"HealthAspectEnumeration": {"@id": "schema:HealthAspectEnumeration"},
"HealthClub": {"@id": "schema:HealthClub"},
"HealthInsurancePlan": {"@id": "schema:HealthInsurancePlan"},
"HealthPlanCostSharingSpecification": {
"@id": "schema:HealthPlanCostSharingSpecification"
},
"HealthPlanFormulary": {"@id": "schema:HealthPlanFormulary"},
"HealthPlanNetwork": {"@id": "schema:HealthPlanNetwork"},
"HealthTopicContent": {"@id": "schema:HealthTopicContent"},
"HearingImpairedSupported": {"@id": "schema:HearingImpairedSupported"},
"Hematologic": {"@id": "schema:Hematologic"},
"HighSchool": {"@id": "schema:HighSchool"},
"HinduDiet": {"@id": "schema:HinduDiet"},
"HinduTemple": {"@id": "schema:HinduTemple"},
"HobbyShop": {"@id": "schema:HobbyShop"},
"HomeAndConstructionBusiness": {"@id": "schema:HomeAndConstructionBusiness"},
"HomeGoodsStore": {"@id": "schema:HomeGoodsStore"},
"Homeopathic": {"@id": "schema:Homeopathic"},
"Hospital": {"@id": "schema:Hospital"},
"Hostel": {"@id": "schema:Hostel"},
"Hotel": {"@id": "schema:Hotel"},
"HotelRoom": {"@id": "schema:HotelRoom"},
"House": {"@id": "schema:House"},
"HousePainter": {"@id": "schema:HousePainter"},
"HowOrWhereHealthAspect": {"@id": "schema:HowOrWhereHealthAspect"},
"HowTo": {"@id": "schema:HowTo"},
"HowToDirection": {"@id": "schema:HowToDirection"},
"HowToItem": {"@id": "schema:HowToItem"},
"HowToSection": {"@id": "schema:HowToSection"},
"HowToStep": {"@id": "schema:HowToStep"},
"HowToSupply": {"@id": "schema:HowToSupply"},
"HowToTip": {"@id": "schema:HowToTip"},
"HowToTool": {"@id": "schema:HowToTool"},
"IceCreamShop": {"@id": "schema:IceCreamShop"},
"IgnoreAction": {"@id": "schema:IgnoreAction"},
"ImageGallery": {"@id": "schema:ImageGallery"},
"ImageObject": {"@id": "schema:ImageObject"},
"ImagingTest": {"@id": "schema:ImagingTest"},
"InForce": {"@id": "schema:InForce"},
"InStock": {"@id": "schema:InStock"},
"InStoreOnly": {"@id": "schema:InStoreOnly"},
"IndividualProduct": {"@id": "schema:IndividualProduct"},
"Infectious": {"@id": "schema:Infectious"},
"InfectiousAgentClass": {"@id": "schema:InfectiousAgentClass"},
"InfectiousDisease": {"@id": "schema:InfectiousDisease"},
"InformAction": {"@id": "schema:InformAction"},
"InsertAction": {"@id": "schema:InsertAction"},
"InstallAction": {"@id": "schema:InstallAction"},
"InsuranceAgency": {"@id": "schema:InsuranceAgency"},
"Intangible": {"@id": "schema:Intangible"},
"Integer": {"@id": "schema:Integer"},
"InteractAction": {"@id": "schema:InteractAction"},
"InteractionCounter": {"@id": "schema:InteractionCounter"},
"InternationalTrial": {"@id": "schema:InternationalTrial"},
"InternetCafe": {"@id": "schema:InternetCafe"},
"InvestmentFund": {"@id": "schema:InvestmentFund"},
"InvestmentOrDeposit": {"@id": "schema:InvestmentOrDeposit"},
"InviteAction": {"@id": "schema:InviteAction"},
"Invoice": {"@id": "schema:Invoice"},
"ItemAvailability": {"@id": "schema:ItemAvailability"},
"ItemList": {"@id": "schema:ItemList"},
"ItemListOrderAscending": {"@id": "schema:ItemListOrderAscending"},
"ItemListOrderDescending": {"@id": "schema:ItemListOrderDescending"},
"ItemListOrderType": {"@id": "schema:ItemListOrderType"},
"ItemListUnordered": {"@id": "schema:ItemListUnordered"},
"ItemPage": {"@id": "schema:ItemPage"},
"JewelryStore": {"@id": "schema:JewelryStore"},
"JobPosting": {"@id": "schema:JobPosting"},
"JoinAction": {"@id": "schema:JoinAction"},
"Joint": {"@id": "schema:Joint"},
"KosherDiet": {"@id": "schema:KosherDiet"},
"LaboratoryScience": {"@id": "schema:LaboratoryScience"},
"LakeBodyOfWater": {"@id": "schema:LakeBodyOfWater"},
"Landform": {"@id": "schema:Landform"},
"LandmarksOrHistoricalBuildings": {"@id": "schema:LandmarksOrHistoricalBuildings"},
"Language": {"@id": "schema:Language"},
"LaserDiscFormat": {"@id": "schema:LaserDiscFormat"},
"LeaveAction": {"@id": "schema:LeaveAction"},
"LeftHandDriving": {"@id": "schema:LeftHandDriving"},
"LegalForceStatus": {"@id": "schema:LegalForceStatus"},
"LegalService": {"@id": "schema:LegalService"},
"LegalValueLevel": {"@id": "schema:LegalValueLevel"},
"Legislation": {"@id": "schema:Legislation"},
"LegislationObject": {"@id": "schema:LegislationObject"},
"LegislativeBuilding": {"@id": "schema:LegislativeBuilding"},
"LeisureTimeActivity": {"@id": "schema:LeisureTimeActivity"},
"LendAction": {"@id": "schema:LendAction"},
"Library": {"@id": "schema:Library"},
"LibrarySystem": {"@id": "schema:LibrarySystem"},
"LifestyleModification": {"@id": "schema:LifestyleModification"},
"Ligament": {"@id": "schema:Ligament"},
"LikeAction": {"@id": "schema:LikeAction"},
"LimitedAvailability": {"@id": "schema:LimitedAvailability"},
"LinkRole": {"@id": "schema:LinkRole"},
"LiquorStore": {"@id": "schema:LiquorStore"},
"ListItem": {"@id": "schema:ListItem"},
"ListenAction": {"@id": "schema:ListenAction"},
"LiteraryEvent": {"@id": "schema:LiteraryEvent"},
"LiveAlbum": {"@id": "schema:LiveAlbum"},
"LiveBlogPosting": {"@id": "schema:LiveBlogPosting"},
"LivingWithHealthAspect": {"@id": "schema:LivingWithHealthAspect"},
"LoanOrCredit": {"@id": "schema:LoanOrCredit"},
"LocalBusiness": {"@id": "schema:LocalBusiness"},
"LocationFeatureSpecification": {"@id": "schema:LocationFeatureSpecification"},
"LockerDelivery": {"@id": "schema:LockerDelivery"},
"Locksmith": {"@id": "schema:Locksmith"},
"LodgingBusiness": {"@id": "schema:LodgingBusiness"},
"LodgingReservation": {"@id": "schema:LodgingReservation"},
"Longitudinal": {"@id": "schema:Longitudinal"},
"LoseAction": {"@id": "schema:LoseAction"},
"LowCalorieDiet": {"@id": "schema:LowCalorieDiet"},
"LowFatDiet": {"@id": "schema:LowFatDiet"},
"LowLactoseDiet": {"@id": "schema:LowLactoseDiet"},
"LowSaltDiet": {"@id": "schema:LowSaltDiet"},
"Lung": {"@id": "schema:Lung"},
"LymphaticVessel": {"@id": "schema:LymphaticVessel"},
"MRI": {"@id": "schema:MRI"},
"Male": {"@id": "schema:Male"},
"Manuscript": {"@id": "schema:Manuscript"},
"Map": {"@id": "schema:Map"},
"MapCategoryType": {"@id": "schema:MapCategoryType"},
"MarryAction": {"@id": "schema:MarryAction"},
"Mass": {"@id": "schema:Mass"},
"MaximumDoseSchedule": {"@id": "schema:MaximumDoseSchedule"},
"MayTreatHealthAspect": {"@id": "schema:MayTreatHealthAspect"},
"MediaGallery": {"@id": "schema:MediaGallery"},
"MediaManipulationRatingEnumeration": {
"@id": "schema:MediaManipulationRatingEnumeration"
},
"MediaObject": {"@id": "schema:MediaObject"},
"MediaReview": {"@id": "schema:MediaReview"},
"MediaSubscription": {"@id": "schema:MediaSubscription"},
"MedicalAudience": {"@id": "schema:MedicalAudience"},
"MedicalBusiness": {"@id": "schema:MedicalBusiness"},
"MedicalCause": {"@id": "schema:MedicalCause"},
"MedicalClinic": {"@id": "schema:MedicalClinic"},
"MedicalCode": {"@id": "schema:MedicalCode"},
"MedicalCondition": {"@id": "schema:MedicalCondition"},
"MedicalConditionStage": {"@id": "schema:MedicalConditionStage"},
"MedicalContraindication": {"@id": "schema:MedicalContraindication"},
"MedicalDevice": {"@id": "schema:MedicalDevice"},
"MedicalDevicePurpose": {"@id": "schema:MedicalDevicePurpose"},
"MedicalEntity": {"@id": "schema:MedicalEntity"},
"MedicalEnumeration": {"@id": "schema:MedicalEnumeration"},
"MedicalEvidenceLevel": {"@id": "schema:MedicalEvidenceLevel"},
"MedicalGuideline": {"@id": "schema:MedicalGuideline"},
"MedicalGuidelineContraindication": {
"@id": "schema:MedicalGuidelineContraindication"
},
"MedicalGuidelineRecommendation": {"@id": "schema:MedicalGuidelineRecommendation"},
"MedicalImagingTechnique": {"@id": "schema:MedicalImagingTechnique"},
"MedicalIndication": {"@id": "schema:MedicalIndication"},
"MedicalIntangible": {"@id": "schema:MedicalIntangible"},
"MedicalObservationalStudy": {"@id": "schema:MedicalObservationalStudy"},
"MedicalObservationalStudyDesign": {
"@id": "schema:MedicalObservationalStudyDesign"
},
"MedicalOrganization": {"@id": "schema:MedicalOrganization"},
"MedicalProcedure": {"@id": "schema:MedicalProcedure"},
"MedicalProcedureType": {"@id": "schema:MedicalProcedureType"},
"MedicalResearcher": {"@id": "schema:MedicalResearcher"},
"MedicalRiskCalculator": {"@id": "schema:MedicalRiskCalculator"},
"MedicalRiskEstimator": {"@id": "schema:MedicalRiskEstimator"},
"MedicalRiskFactor": {"@id": "schema:MedicalRiskFactor"},
"MedicalRiskScore": {"@id": "schema:MedicalRiskScore"},
"MedicalScholarlyArticle": {"@id": "schema:MedicalScholarlyArticle"},
"MedicalSign": {"@id": "schema:MedicalSign"},
"MedicalSignOrSymptom": {"@id": "schema:MedicalSignOrSymptom"},
"MedicalSpecialty": {"@id": "schema:MedicalSpecialty"},
"MedicalStudy": {"@id": "schema:MedicalStudy"},
"MedicalStudyStatus": {"@id": "schema:MedicalStudyStatus"},
"MedicalSymptom": {"@id": "schema:MedicalSymptom"},
"MedicalTest": {"@id": "schema:MedicalTest"},
"MedicalTestPanel": {"@id": "schema:MedicalTestPanel"},
"MedicalTherapy": {"@id": "schema:MedicalTherapy"},
"MedicalTrial": {"@id": "schema:MedicalTrial"},
"MedicalTrialDesign": {"@id": "schema:MedicalTrialDesign"},
"MedicalWebPage": {"@id": "schema:MedicalWebPage"},
"MedicineSystem": {"@id": "schema:MedicineSystem"},
"MeetingRoom": {"@id": "schema:MeetingRoom"},
"MensClothingStore": {"@id": "schema:MensClothingStore"},
"Menu": {"@id": "schema:Menu"},
"MenuItem": {"@id": "schema:MenuItem"},
"MenuSection": {"@id": "schema:MenuSection"},
"MerchantReturnEnumeration": {"@id": "schema:MerchantReturnEnumeration"},
"MerchantReturnFiniteReturnWindow": {
"@id": "schema:MerchantReturnFiniteReturnWindow"
},
"MerchantReturnNotPermitted": {"@id": "schema:MerchantReturnNotPermitted"},
"MerchantReturnPolicy": {"@id": "schema:MerchantReturnPolicy"},
"MerchantReturnUnlimitedWindow": {"@id": "schema:MerchantReturnUnlimitedWindow"},
"MerchantReturnUnspecified": {"@id": "schema:MerchantReturnUnspecified"},
"Message": {"@id": "schema:Message"},
"MiddleSchool": {"@id": "schema:MiddleSchool"},
"Midwifery": {"@id": "schema:Midwifery"},
"MisconceptionsHealthAspect": {"@id": "schema:MisconceptionsHealthAspect"},
"MissingContext": {"@id": "schema:MissingContext"},
"MixedEventAttendanceMode": {"@id": "schema:MixedEventAttendanceMode"},
"MixtapeAlbum": {"@id": "schema:MixtapeAlbum"},
"MobileApplication": {"@id": "schema:MobileApplication"},
"MobilePhoneStore": {"@id": "schema:MobilePhoneStore"},
"Monday": {"@id": "schema:Monday"},
"MonetaryAmount": {"@id": "schema:MonetaryAmount"},
"MonetaryAmountDistribution": {"@id": "schema:MonetaryAmountDistribution"},
"MonetaryGrant": {"@id": "schema:MonetaryGrant"},
"MoneyTransfer": {"@id": "schema:MoneyTransfer"},
"MortgageLoan": {"@id": "schema:MortgageLoan"},
"Mosque": {"@id": "schema:Mosque"},
"Motel": {"@id": "schema:Motel"},
"Motorcycle": {"@id": "schema:Motorcycle"},
"MotorcycleDealer": {"@id": "schema:MotorcycleDealer"},
"MotorcycleRepair": {"@id": "schema:MotorcycleRepair"},
"MotorizedBicycle": {"@id": "schema:MotorizedBicycle"},
"Mountain": {"@id": "schema:Mountain"},
"MoveAction": {"@id": "schema:MoveAction"},
"Movie": {"@id": "schema:Movie"},
"MovieClip": {"@id": "schema:MovieClip"},
"MovieRentalStore": {"@id": "schema:MovieRentalStore"},
"MovieSeries": {"@id": "schema:MovieSeries"},
"MovieTheater": {"@id": "schema:MovieTheater"},
"MovingCompany": {"@id": "schema:MovingCompany"},
"MultiCenterTrial": {"@id": "schema:MultiCenterTrial"},
"MultiPlayer": {"@id": "schema:MultiPlayer"},
"MulticellularParasite": {"@id": "schema:MulticellularParasite"},
"Muscle": {"@id": "schema:Muscle"},
"Musculoskeletal": {"@id": "schema:Musculoskeletal"},
"MusculoskeletalExam": {"@id": "schema:MusculoskeletalExam"},
"Museum": {"@id": "schema:Museum"},
"MusicAlbum": {"@id": "schema:MusicAlbum"},
"MusicAlbumProductionType": {"@id": "schema:MusicAlbumProductionType"},
"MusicAlbumReleaseType": {"@id": "schema:MusicAlbumReleaseType"},
"MusicComposition": {"@id": "schema:MusicComposition"},
"MusicEvent": {"@id": "schema:MusicEvent"},
"MusicGroup": {"@id": "schema:MusicGroup"},
"MusicPlaylist": {"@id": "schema:MusicPlaylist"},
"MusicRecording": {"@id": "schema:MusicRecording"},
"MusicRelease": {"@id": "schema:MusicRelease"},
"MusicReleaseFormatType": {"@id": "schema:MusicReleaseFormatType"},
"MusicStore": {"@id": "schema:MusicStore"},
"MusicVenue": {"@id": "schema:MusicVenue"},
"MusicVideoObject": {"@id": "schema:MusicVideoObject"},
"NGO": {"@id": "schema:NGO"},
"NailSalon": {"@id": "schema:NailSalon"},
"Neck": {"@id": "schema:Neck"},
"Nerve": {"@id": "schema:Nerve"},
"Neuro": {"@id": "schema:Neuro"},
"Neurologic": {"@id": "schema:Neurologic"},
"NewCondition": {"@id": "schema:NewCondition"},
"NewsArticle": {"@id": "schema:NewsArticle"},
"NewsMediaOrganization": {"@id": "schema:NewsMediaOrganization"},
"Newspaper": {"@id": "schema:Newspaper"},
"NightClub": {"@id": "schema:NightClub"},
"NoninvasiveProcedure": {"@id": "schema:NoninvasiveProcedure"},
"Nose": {"@id": "schema:Nose"},
"NotInForce": {"@id": "schema:NotInForce"},
"NotYetRecruiting": {"@id": "schema:NotYetRecruiting"},
"Notary": {"@id": "schema:Notary"},
"NoteDigitalDocument": {"@id": "schema:NoteDigitalDocument"},
"Number": {"@id": "schema:Number"},
"Nursing": {"@id": "schema:Nursing"},
"NutritionInformation": {"@id": "schema:NutritionInformation"},
"OTC": {"@id": "schema:OTC"},
"Observation": {"@id": "schema:Observation"},
"Observational": {"@id": "schema:Observational"},
"Obstetric": {"@id": "schema:Obstetric"},
"Occupation": {"@id": "schema:Occupation"},
"OccupationalActivity": {"@id": "schema:OccupationalActivity"},
"OccupationalTherapy": {"@id": "schema:OccupationalTherapy"},
"OceanBodyOfWater": {"@id": "schema:OceanBodyOfWater"},
"Offer": {"@id": "schema:Offer"},
"OfferCatalog": {"@id": "schema:OfferCatalog"},
"OfferForLease": {"@id": "schema:OfferForLease"},
"OfferForPurchase": {"@id": "schema:OfferForPurchase"},
"OfferItemCondition": {"@id": "schema:OfferItemCondition"},
"OfficeEquipmentStore": {"@id": "schema:OfficeEquipmentStore"},
"OfficialLegalValue": {"@id": "schema:OfficialLegalValue"},
"OfflineEventAttendanceMode": {"@id": "schema:OfflineEventAttendanceMode"},
"OfflinePermanently": {"@id": "schema:OfflinePermanently"},
"OfflineTemporarily": {"@id": "schema:OfflineTemporarily"},
"OnDemandEvent": {"@id": "schema:OnDemandEvent"},
"OnSitePickup": {"@id": "schema:OnSitePickup"},
"Oncologic": {"@id": "schema:Oncologic"},
"Online": {"@id": "schema:Online"},
"OnlineEventAttendanceMode": {"@id": "schema:OnlineEventAttendanceMode"},
"OnlineFull": {"@id": "schema:OnlineFull"},
"OnlineOnly": {"@id": "schema:OnlineOnly"},
"OpenTrial": {"@id": "schema:OpenTrial"},
"OpeningHoursSpecification": {"@id": "schema:OpeningHoursSpecification"},
"OpinionNewsArticle": {"@id": "schema:OpinionNewsArticle"},
"Optician": {"@id": "schema:Optician"},
"Optometric": {"@id": "schema:Optometric"},
"Order": {"@id": "schema:Order"},
"OrderAction": {"@id": "schema:OrderAction"},
"OrderCancelled": {"@id": "schema:OrderCancelled"},
"OrderDelivered": {"@id": "schema:OrderDelivered"},
"OrderInTransit": {"@id": "schema:OrderInTransit"},
"OrderItem": {"@id": "schema:OrderItem"},
"OrderPaymentDue": {"@id": "schema:OrderPaymentDue"},
"OrderPickupAvailable": {"@id": "schema:OrderPickupAvailable"},
"OrderProblem": {"@id": "schema:OrderProblem"},
"OrderProcessing": {"@id": "schema:OrderProcessing"},
"OrderReturned": {"@id": "schema:OrderReturned"},
"OrderStatus": {"@id": "schema:OrderStatus"},
"Organization": {"@id": "schema:Organization"},
"OrganizationRole": {"@id": "schema:OrganizationRole"},
"OrganizeAction": {"@id": "schema:OrganizeAction"},
"OriginalShippingFees": {"@id": "schema:OriginalShippingFees"},
"Osteopathic": {"@id": "schema:Osteopathic"},
"Otolaryngologic": {"@id": "schema:Otolaryngologic"},
"OutOfStock": {"@id": "schema:OutOfStock"},
"OutletStore": {"@id": "schema:OutletStore"},
"OverviewHealthAspect": {"@id": "schema:OverviewHealthAspect"},
"OwnershipInfo": {"@id": "schema:OwnershipInfo"},
"PET": {"@id": "schema:PET"},
"PaintAction": {"@id": "schema:PaintAction"},
"Painting": {"@id": "schema:Painting"},
"PalliativeProcedure": {"@id": "schema:PalliativeProcedure"},
"Paperback": {"@id": "schema:Paperback"},
"ParcelDelivery": {"@id": "schema:ParcelDelivery"},
"ParcelService": {"@id": "schema:ParcelService"},
"ParentAudience": {"@id": "schema:ParentAudience"},
"Park": {"@id": "schema:Park"},
"ParkingFacility": {"@id": "schema:ParkingFacility"},
"ParkingMap": {"@id": "schema:ParkingMap"},
"PartiallyInForce": {"@id": "schema:PartiallyInForce"},
"Pathology": {"@id": "schema:Pathology"},
"PathologyTest": {"@id": "schema:PathologyTest"},
"Patient": {"@id": "schema:Patient"},
"PatientExperienceHealthAspect": {"@id": "schema:PatientExperienceHealthAspect"},
"PawnShop": {"@id": "schema:PawnShop"},
"PayAction": {"@id": "schema:PayAction"},
"PaymentAutomaticallyApplied": {"@id": "schema:PaymentAutomaticallyApplied"},
"PaymentCard": {"@id": "schema:PaymentCard"},
"PaymentChargeSpecification": {"@id": "schema:PaymentChargeSpecification"},
"PaymentComplete": {"@id": "schema:PaymentComplete"},
"PaymentDeclined": {"@id": "schema:PaymentDeclined"},
"PaymentDue": {"@id": "schema:PaymentDue"},
"PaymentMethod": {"@id": "schema:PaymentMethod"},
"PaymentPastDue": {"@id": "schema:PaymentPastDue"},
"PaymentService": {"@id": "schema:PaymentService"},
"PaymentStatusType": {"@id": "schema:PaymentStatusType"},
"Pediatric": {"@id": "schema:Pediatric"},
"PeopleAudience": {"@id": "schema:PeopleAudience"},
"PercutaneousProcedure": {"@id": "schema:PercutaneousProcedure"},
"PerformAction": {"@id": "schema:PerformAction"},
"PerformanceRole": {"@id": "schema:PerformanceRole"},
"PerformingArtsTheater": {"@id": "schema:PerformingArtsTheater"},
"PerformingGroup": {"@id": "schema:PerformingGroup"},
"Periodical": {"@id": "schema:Periodical"},
"Permit": {"@id": "schema:Permit"},
"Person": {"@id": "schema:Person"},
"PetStore": {"@id": "schema:PetStore"},
"Pharmacy": {"@id": "schema:Pharmacy"},
"PharmacySpecialty": {"@id": "schema:PharmacySpecialty"},
"Photograph": {"@id": "schema:Photograph"},
"PhotographAction": {"@id": "schema:PhotographAction"},
"PhysicalActivity": {"@id": "schema:PhysicalActivity"},
"PhysicalActivityCategory": {"@id": "schema:PhysicalActivityCategory"},
"PhysicalExam": {"@id": "schema:PhysicalExam"},
"PhysicalTherapy": {"@id": "schema:PhysicalTherapy"},
"Physician": {"@id": "schema:Physician"},
"Physiotherapy": {"@id": "schema:Physiotherapy"},
"Place": {"@id": "schema:Place"},
"PlaceOfWorship": {"@id": "schema:PlaceOfWorship"},
"PlaceboControlledTrial": {"@id": "schema:PlaceboControlledTrial"},
"PlanAction": {"@id": "schema:PlanAction"},
"PlasticSurgery": {"@id": "schema:PlasticSurgery"},
"Play": {"@id": "schema:Play"},
"PlayAction": {"@id": "schema:PlayAction"},
"Playground": {"@id": "schema:Playground"},
"Plumber": {"@id": "schema:Plumber"},
"PodcastEpisode": {"@id": "schema:PodcastEpisode"},
"PodcastSeason": {"@id": "schema:PodcastSeason"},
"PodcastSeries": {"@id": "schema:PodcastSeries"},
"Podiatric": {"@id": "schema:Podiatric"},
"PoliceStation": {"@id": "schema:PoliceStation"},
"Pond": {"@id": "schema:Pond"},
"PostOffice": {"@id": "schema:PostOffice"},
"PostalAddress": {"@id": "schema:PostalAddress"},
"Poster": {"@id": "schema:Poster"},
"PotentialActionStatus": {"@id": "schema:PotentialActionStatus"},
"PreOrder": {"@id": "schema:PreOrder"},
"PreOrderAction": {"@id": "schema:PreOrderAction"},
"PreSale": {"@id": "schema:PreSale"},
"PrependAction": {"@id": "schema:PrependAction"},
"Preschool": {"@id": "schema:Preschool"},
"PrescriptionOnly": {"@id": "schema:PrescriptionOnly"},
"PresentationDigitalDocument": {"@id": "schema:PresentationDigitalDocument"},
"PreventionHealthAspect": {"@id": "schema:PreventionHealthAspect"},
"PreventionIndication": {"@id": "schema:PreventionIndication"},
"PriceSpecification": {"@id": "schema:PriceSpecification"},
"PrimaryCare": {"@id": "schema:PrimaryCare"},
"Prion": {"@id": "schema:Prion"},
"Product": {"@id": "schema:Product"},
"ProductModel": {"@id": "schema:ProductModel"},
"ProductReturnEnumeration": {"@id": "schema:ProductReturnEnumeration"},
"ProductReturnFiniteReturnWindow": {
"@id": "schema:ProductReturnFiniteReturnWindow"
},
"ProductReturnNotPermitted": {"@id": "schema:ProductReturnNotPermitted"},
"ProductReturnPolicy": {"@id": "schema:ProductReturnPolicy"},
"ProductReturnUnlimitedWindow": {"@id": "schema:ProductReturnUnlimitedWindow"},
"ProductReturnUnspecified": {"@id": "schema:ProductReturnUnspecified"},
"ProfessionalService": {"@id": "schema:ProfessionalService"},
"ProfilePage": {"@id": "schema:ProfilePage"},
"PrognosisHealthAspect": {"@id": "schema:PrognosisHealthAspect"},
"ProgramMembership": {"@id": "schema:ProgramMembership"},
"Project": {"@id": "schema:Project"},
"PronounceableText": {"@id": "schema:PronounceableText"},
"Property": {"@id": "schema:Property"},
"PropertyValue": {"@id": "schema:PropertyValue"},
"PropertyValueSpecification": {"@id": "schema:PropertyValueSpecification"},
"Protozoa": {"@id": "schema:Protozoa"},
"Psychiatric": {"@id": "schema:Psychiatric"},
"PsychologicalTreatment": {"@id": "schema:PsychologicalTreatment"},
"PublicHealth": {"@id": "schema:PublicHealth"},
"PublicHolidays": {"@id": "schema:PublicHolidays"},
"PublicSwimmingPool": {"@id": "schema:PublicSwimmingPool"},
"PublicToilet": {"@id": "schema:PublicToilet"},
"PublicationEvent": {"@id": "schema:PublicationEvent"},
"PublicationIssue": {"@id": "schema:PublicationIssue"},
"PublicationVolume": {"@id": "schema:PublicationVolume"},
"Pulmonary": {"@id": "schema:Pulmonary"},
"QAPage": {"@id": "schema:QAPage"},
"QualitativeValue": {"@id": "schema:QualitativeValue"},
"QuantitativeValue": {"@id": "schema:QuantitativeValue"},
"QuantitativeValueDistribution": {"@id": "schema:QuantitativeValueDistribution"},
"Quantity": {"@id": "schema:Quantity"},
"Question": {"@id": "schema:Question"},
"Quotation": {"@id": "schema:Quotation"},
"QuoteAction": {"@id": "schema:QuoteAction"},
"RVPark": {"@id": "schema:RVPark"},
"RadiationTherapy": {"@id": "schema:RadiationTherapy"},
"RadioBroadcastService": {"@id": "schema:RadioBroadcastService"},
"RadioChannel": {"@id": "schema:RadioChannel"},
"RadioClip": {"@id": "schema:RadioClip"},
"RadioEpisode": {"@id": "schema:RadioEpisode"},
"RadioSeason": {"@id": "schema:RadioSeason"},
"RadioSeries": {"@id": "schema:RadioSeries"},
"RadioStation": {"@id": "schema:RadioStation"},
"Radiography": {"@id": "schema:Radiography"},
"RandomizedTrial": {"@id": "schema:RandomizedTrial"},
"Rating": {"@id": "schema:Rating"},
"ReactAction": {"@id": "schema:ReactAction"},
"ReadAction": {"@id": "schema:ReadAction"},
"ReadPermission": {"@id": "schema:ReadPermission"},
"RealEstateAgent": {"@id": "schema:RealEstateAgent"},
"RealEstateListing": {"@id": "schema:RealEstateListing"},
"RearWheelDriveConfiguration": {"@id": "schema:RearWheelDriveConfiguration"},
"ReceiveAction": {"@id": "schema:ReceiveAction"},
"Recipe": {"@id": "schema:Recipe"},
"Recommendation": {"@id": "schema:Recommendation"},
"RecommendedDoseSchedule": {"@id": "schema:RecommendedDoseSchedule"},
"Recruiting": {"@id": "schema:Recruiting"},
"RecyclingCenter": {"@id": "schema:RecyclingCenter"},
"RefundTypeEnumeration": {"@id": "schema:RefundTypeEnumeration"},
"RefurbishedCondition": {"@id": "schema:RefurbishedCondition"},
"RegisterAction": {"@id": "schema:RegisterAction"},
"Registry": {"@id": "schema:Registry"},
"ReimbursementCap": {"@id": "schema:ReimbursementCap"},
"RejectAction": {"@id": "schema:RejectAction"},
"RelatedTopicsHealthAspect": {"@id": "schema:RelatedTopicsHealthAspect"},
"RemixAlbum": {"@id": "schema:RemixAlbum"},
"Renal": {"@id": "schema:Renal"},
"RentAction": {"@id": "schema:RentAction"},
"RentalCarReservation": {"@id": "schema:RentalCarReservation"},
"RentalVehicleUsage": {"@id": "schema:RentalVehicleUsage"},
"RepaymentSpecification": {"@id": "schema:RepaymentSpecification"},
"ReplaceAction": {"@id": "schema:ReplaceAction"},
"ReplyAction": {"@id": "schema:ReplyAction"},
"Report": {"@id": "schema:Report"},
"ReportageNewsArticle": {"@id": "schema:ReportageNewsArticle"},
"ReportedDoseSchedule": {"@id": "schema:ReportedDoseSchedule"},
"ResearchProject": {"@id": "schema:ResearchProject"},
"Researcher": {"@id": "schema:Researcher"},
"Reservation": {"@id": "schema:Reservation"},
"ReservationCancelled": {"@id": "schema:ReservationCancelled"},
"ReservationConfirmed": {"@id": "schema:ReservationConfirmed"},
"ReservationHold": {"@id": "schema:ReservationHold"},
"ReservationPackage": {"@id": "schema:ReservationPackage"},
"ReservationPending": {"@id": "schema:ReservationPending"},
"ReservationStatusType": {"@id": "schema:ReservationStatusType"},
"ReserveAction": {"@id": "schema:ReserveAction"},
"Reservoir": {"@id": "schema:Reservoir"},
"Residence": {"@id": "schema:Residence"},
"Resort": {"@id": "schema:Resort"},
"RespiratoryTherapy": {"@id": "schema:RespiratoryTherapy"},
"Restaurant": {"@id": "schema:Restaurant"},
"RestockingFees": {"@id": "schema:RestockingFees"},
"RestrictedDiet": {"@id": "schema:RestrictedDiet"},
"ResultsAvailable": {"@id": "schema:ResultsAvailable"},
"ResultsNotAvailable": {"@id": "schema:ResultsNotAvailable"},
"ResumeAction": {"@id": "schema:ResumeAction"},
"Retail": {"@id": "schema:Retail"},
"ReturnAction": {"@id": "schema:ReturnAction"},
"ReturnFeesEnumeration": {"@id": "schema:ReturnFeesEnumeration"},
"ReturnShippingFees": {"@id": "schema:ReturnShippingFees"},
"Review": {"@id": "schema:Review"},
"ReviewAction": {"@id": "schema:ReviewAction"},
"ReviewNewsArticle": {"@id": "schema:ReviewNewsArticle"},
"Rheumatologic": {"@id": "schema:Rheumatologic"},
"RightHandDriving": {"@id": "schema:RightHandDriving"},
"RisksOrComplicationsHealthAspect": {
"@id": "schema:RisksOrComplicationsHealthAspect"
},
"RiverBodyOfWater": {"@id": "schema:RiverBodyOfWater"},
"Role": {"@id": "schema:Role"},
"RoofingContractor": {"@id": "schema:RoofingContractor"},
"Room": {"@id": "schema:Room"},
"RsvpAction": {"@id": "schema:RsvpAction"},
"RsvpResponseMaybe": {"@id": "schema:RsvpResponseMaybe"},
"RsvpResponseNo": {"@id": "schema:RsvpResponseNo"},
"RsvpResponseType": {"@id": "schema:RsvpResponseType"},
"RsvpResponseYes": {"@id": "schema:RsvpResponseYes"},
"SaleEvent": {"@id": "schema:SaleEvent"},
"SatiricalArticle": {"@id": "schema:SatiricalArticle"},
"Saturday": {"@id": "schema:Saturday"},
"Schedule": {"@id": "schema:Schedule"},
"ScheduleAction": {"@id": "schema:ScheduleAction"},
"ScholarlyArticle": {"@id": "schema:ScholarlyArticle"},
"School": {"@id": "schema:School"},
"SchoolDistrict": {"@id": "schema:SchoolDistrict"},
"ScreeningEvent": {"@id": "schema:ScreeningEvent"},
"ScreeningHealthAspect": {"@id": "schema:ScreeningHealthAspect"},
"Sculpture": {"@id": "schema:Sculpture"},
"SeaBodyOfWater": {"@id": "schema:SeaBodyOfWater"},
"SearchAction": {"@id": "schema:SearchAction"},
"SearchResultsPage": {"@id": "schema:SearchResultsPage"},
"Season": {"@id": "schema:Season"},
"Seat": {"@id": "schema:Seat"},
"SeatingMap": {"@id": "schema:SeatingMap"},
"SeeDoctorHealthAspect": {"@id": "schema:SeeDoctorHealthAspect"},
"SelfCareHealthAspect": {"@id": "schema:SelfCareHealthAspect"},
"SelfStorage": {"@id": "schema:SelfStorage"},
"SellAction": {"@id": "schema:SellAction"},
"SendAction": {"@id": "schema:SendAction"},
"Series": {"@id": "schema:Series"},
"Service": {"@id": "schema:Service"},
"ServiceChannel": {"@id": "schema:ServiceChannel"},
"ShareAction": {"@id": "schema:ShareAction"},
"SheetMusic": {"@id": "schema:SheetMusic"},
"ShoeStore": {"@id": "schema:ShoeStore"},
"ShoppingCenter": {"@id": "schema:ShoppingCenter"},
"ShortStory": {"@id": "schema:ShortStory"},
"SideEffectsHealthAspect": {"@id": "schema:SideEffectsHealthAspect"},
"SingleBlindedTrial": {"@id": "schema:SingleBlindedTrial"},
"SingleCenterTrial": {"@id": "schema:SingleCenterTrial"},
"SingleFamilyResidence": {"@id": "schema:SingleFamilyResidence"},
"SinglePlayer": {"@id": "schema:SinglePlayer"},
"SingleRelease": {"@id": "schema:SingleRelease"},
"SiteNavigationElement": {"@id": "schema:SiteNavigationElement"},
"SkiResort": {"@id": "schema:SkiResort"},
"Skin": {"@id": "schema:Skin"},
"SocialEvent": {"@id": "schema:SocialEvent"},
"SocialMediaPosting": {"@id": "schema:SocialMediaPosting"},
"SoftwareApplication": {"@id": "schema:SoftwareApplication"},
"SoftwareSourceCode": {"@id": "schema:SoftwareSourceCode"},
"SoldOut": {"@id": "schema:SoldOut"},
"SomeProducts": {"@id": "schema:SomeProducts"},
"SoundtrackAlbum": {"@id": "schema:SoundtrackAlbum"},
"SpeakableSpecification": {"@id": "schema:SpeakableSpecification"},
"SpecialAnnouncement": {"@id": "schema:SpecialAnnouncement"},
"Specialty": {"@id": "schema:Specialty"},
"SpeechPathology": {"@id": "schema:SpeechPathology"},
"SpokenWordAlbum": {"@id": "schema:SpokenWordAlbum"},
"SportingGoodsStore": {"@id": "schema:SportingGoodsStore"},
"SportsActivityLocation": {"@id": "schema:SportsActivityLocation"},
"SportsClub": {"@id": "schema:SportsClub"},
"SportsEvent": {"@id": "schema:SportsEvent"},
"SportsOrganization": {"@id": "schema:SportsOrganization"},
"SportsTeam": {"@id": "schema:SportsTeam"},
"SpreadsheetDigitalDocument": {"@id": "schema:SpreadsheetDigitalDocument"},
"StadiumOrArena": {"@id": "schema:StadiumOrArena"},
"StagesHealthAspect": {"@id": "schema:StagesHealthAspect"},
"State": {"@id": "schema:State"},
"StatisticalPopulation": {"@id": "schema:StatisticalPopulation"},
"SteeringPositionValue": {"@id": "schema:SteeringPositionValue"},
"Store": {"@id": "schema:Store"},
"StoreCreditRefund": {"@id": "schema:StoreCreditRefund"},
"StrengthTraining": {"@id": "schema:StrengthTraining"},
"StructuredValue": {"@id": "schema:StructuredValue"},
"StudioAlbum": {"@id": "schema:StudioAlbum"},
"StupidType": {"@id": "schema:StupidType"},
"SubscribeAction": {"@id": "schema:SubscribeAction"},
"Substance": {"@id": "schema:Substance"},
"SubwayStation": {"@id": "schema:SubwayStation"},
"Suite": {"@id": "schema:Suite"},
"Sunday": {"@id": "schema:Sunday"},
"SuperficialAnatomy": {"@id": "schema:SuperficialAnatomy"},
"Surgical": {"@id": "schema:Surgical"},
"SurgicalProcedure": {"@id": "schema:SurgicalProcedure"},
"SuspendAction": {"@id": "schema:SuspendAction"},
"Suspended": {"@id": "schema:Suspended"},
"SymptomsHealthAspect": {"@id": "schema:SymptomsHealthAspect"},
"Synagogue": {"@id": "schema:Synagogue"},
"TVClip": {"@id": "schema:TVClip"},
"TVEpisode": {"@id": "schema:TVEpisode"},
"TVSeason": {"@id": "schema:TVSeason"},
"TVSeries": {"@id": "schema:TVSeries"},
"Table": {"@id": "schema:Table"},
"TakeAction": {"@id": "schema:TakeAction"},
"TattooParlor": {"@id": "schema:TattooParlor"},
"Taxi": {"@id": "schema:Taxi"},
"TaxiReservation": {"@id": "schema:TaxiReservation"},
"TaxiService": {"@id": "schema:TaxiService"},
"TaxiStand": {"@id": "schema:TaxiStand"},
"TaxiVehicleUsage": {"@id": "schema:TaxiVehicleUsage"},
"TechArticle": {"@id": "schema:TechArticle"},
"TelevisionChannel": {"@id": "schema:TelevisionChannel"},
"TelevisionStation": {"@id": "schema:TelevisionStation"},
"TennisComplex": {"@id": "schema:TennisComplex"},
"Terminated": {"@id": "schema:Terminated"},
"Text": {"@id": "schema:Text"},
"TextDigitalDocument": {"@id": "schema:TextDigitalDocument"},
"TheaterEvent": {"@id": "schema:TheaterEvent"},
"TheaterGroup": {"@id": "schema:TheaterGroup"},
"Therapeutic": {"@id": "schema:Therapeutic"},
"TherapeuticProcedure": {"@id": "schema:TherapeuticProcedure"},
"Thesis": {"@id": "schema:Thesis"},
"Thing": {"@id": "schema:Thing"},
"Throat": {"@id": "schema:Throat"},
"Thursday": {"@id": "schema:Thursday"},
"Ticket": {"@id": "schema:Ticket"},
"TieAction": {"@id": "schema:TieAction"},
"Time": {"@id": "schema:Time"},
"TipAction": {"@id": "schema:TipAction"},
"TireShop": {"@id": "schema:TireShop"},
"TollFree": {"@id": "schema:TollFree"},
"TouristAttraction": {"@id": "schema:TouristAttraction"},
"TouristDestination": {"@id": "schema:TouristDestination"},
"TouristInformationCenter": {"@id": "schema:TouristInformationCenter"},
"TouristTrip": {"@id": "schema:TouristTrip"},
"Toxicologic": {"@id": "schema:Toxicologic"},
"ToyStore": {"@id": "schema:ToyStore"},
"TrackAction": {"@id": "schema:TrackAction"},
"TradeAction": {"@id": "schema:TradeAction"},
"TraditionalChinese": {"@id": "schema:TraditionalChinese"},
"TrainReservation": {"@id": "schema:TrainReservation"},
"TrainStation": {"@id": "schema:TrainStation"},
"TrainTrip": {"@id": "schema:TrainTrip"},
"TransferAction": {"@id": "schema:TransferAction"},
"TransitMap": {"@id": "schema:TransitMap"},
"TravelAction": {"@id": "schema:TravelAction"},
"TravelAgency": {"@id": "schema:TravelAgency"},
"TreatmentIndication": {"@id": "schema:TreatmentIndication"},
"TreatmentsHealthAspect": {"@id": "schema:TreatmentsHealthAspect"},
"Trip": {"@id": "schema:Trip"},
"TripleBlindedTrial": {"@id": "schema:TripleBlindedTrial"},
"True": {"@id": "schema:True"},
"Tuesday": {"@id": "schema:Tuesday"},
"TypeAndQuantityNode": {"@id": "schema:TypeAndQuantityNode"},
"TypesHealthAspect": {"@id": "schema:TypesHealthAspect"},
"URL": {"@id": "schema:URL"},
"Ultrasound": {"@id": "schema:Ultrasound"},
"UnRegisterAction": {"@id": "schema:UnRegisterAction"},
"UnitPriceSpecification": {"@id": "schema:UnitPriceSpecification"},
"UnofficialLegalValue": {"@id": "schema:UnofficialLegalValue"},
"UpdateAction": {"@id": "schema:UpdateAction"},
"Urologic": {"@id": "schema:Urologic"},
"UsageOrScheduleHealthAspect": {"@id": "schema:UsageOrScheduleHealthAspect"},
"UseAction": {"@id": "schema:UseAction"},
"UsedCondition": {"@id": "schema:UsedCondition"},
"UserBlocks": {"@id": "schema:UserBlocks"},
"UserCheckins": {"@id": "schema:UserCheckins"},
"UserComments": {"@id": "schema:UserComments"},
"UserDownloads": {"@id": "schema:UserDownloads"},
"UserInteraction": {"@id": "schema:UserInteraction"},
"UserLikes": {"@id": "schema:UserLikes"},
"UserPageVisits": {"@id": "schema:UserPageVisits"},
"UserPlays": {"@id": "schema:UserPlays"},
"UserPlusOnes": {"@id": "schema:UserPlusOnes"},
"UserReview": {"@id": "schema:UserReview"},
"UserTweets": {"@id": "schema:UserTweets"},
"VeganDiet": {"@id": "schema:VeganDiet"},
"VegetarianDiet": {"@id": "schema:VegetarianDiet"},
"Vehicle": {"@id": "schema:Vehicle"},
"Vein": {"@id": "schema:Vein"},
"VenueMap": {"@id": "schema:VenueMap"},
"Vessel": {"@id": "schema:Vessel"},
"VeterinaryCare": {"@id": "schema:VeterinaryCare"},
"VideoGallery": {"@id": "schema:VideoGallery"},
"VideoGame": {"@id": "schema:VideoGame"},
"VideoGameClip": {"@id": "schema:VideoGameClip"},
"VideoGameSeries": {"@id": "schema:VideoGameSeries"},
"VideoObject": {"@id": "schema:VideoObject"},
"ViewAction": {"@id": "schema:ViewAction"},
"VinylFormat": {"@id": "schema:VinylFormat"},
"VirtualLocation": {"@id": "schema:VirtualLocation"},
"Virus": {"@id": "schema:Virus"},
"VisualArtsEvent": {"@id": "schema:VisualArtsEvent"},
"VisualArtwork": {"@id": "schema:VisualArtwork"},
"VitalSign": {"@id": "schema:VitalSign"},
"Volcano": {"@id": "schema:Volcano"},
"VoteAction": {"@id": "schema:VoteAction"},
"WPAdBlock": {"@id": "schema:WPAdBlock"},
"WPFooter": {"@id": "schema:WPFooter"},
"WPHeader": {"@id": "schema:WPHeader"},
"WPSideBar": {"@id": "schema:WPSideBar"},
"WantAction": {"@id": "schema:WantAction"},
"WarrantyPromise": {"@id": "schema:WarrantyPromise"},
"WarrantyScope": {"@id": "schema:WarrantyScope"},
"WatchAction": {"@id": "schema:WatchAction"},
"Waterfall": {"@id": "schema:Waterfall"},
"WearAction": {"@id": "schema:WearAction"},
"WebAPI": {"@id": "schema:WebAPI"},
"WebApplication": {"@id": "schema:WebApplication"},
"WebContent": {"@id": "schema:WebContent"},
"WebPage": {"@id": "schema:WebPage"},
"WebPageElement": {"@id": "schema:WebPageElement"},
"WebSite": {"@id": "schema:WebSite"},
"Wednesday": {"@id": "schema:Wednesday"},
"WesternConventional": {"@id": "schema:WesternConventional"},
"Wholesale": {"@id": "schema:Wholesale"},
"WholesaleStore": {"@id": "schema:WholesaleStore"},
"WinAction": {"@id": "schema:WinAction"},
"Winery": {"@id": "schema:Winery"},
"Withdrawn": {"@id": "schema:Withdrawn"},
"WorkBasedProgram": {"@id": "schema:WorkBasedProgram"},
"WorkersUnion": {"@id": "schema:WorkersUnion"},
"WriteAction": {"@id": "schema:WriteAction"},
"WritePermission": {"@id": "schema:WritePermission"},
"XPathType": {"@id": "schema:XPathType"},
"XRay": {"@id": "schema:XRay"},
"ZoneBoardingPolicy": {"@id": "schema:ZoneBoardingPolicy"},
"Zoo": {"@id": "schema:Zoo"},
"about": {"@id": "schema:about"},
"abridged": {"@id": "schema:abridged"},
"abstract": {"@id": "schema:abstract"},
"accelerationTime": {"@id": "schema:accelerationTime"},
"acceptedAnswer": {"@id": "schema:acceptedAnswer"},
"acceptedOffer": {"@id": "schema:acceptedOffer"},
"acceptedPaymentMethod": {"@id": "schema:acceptedPaymentMethod"},
"acceptsReservations": {"@id": "schema:acceptsReservations"},
"accessCode": {"@id": "schema:accessCode"},
"accessMode": {"@id": "schema:accessMode"},
"accessModeSufficient": {"@id": "schema:accessModeSufficient"},
"accessibilityAPI": {"@id": "schema:accessibilityAPI"},
"accessibilityControl": {"@id": "schema:accessibilityControl"},
"accessibilityFeature": {"@id": "schema:accessibilityFeature"},
"accessibilityHazard": {"@id": "schema:accessibilityHazard"},
"accessibilitySummary": {"@id": "schema:accessibilitySummary"},
"accommodationCategory": {"@id": "schema:accommodationCategory"},
"accommodationFloorPlan": {"@id": "schema:accommodationFloorPlan"},
"accountId": {"@id": "schema:accountId"},
"accountMinimumInflow": {"@id": "schema:accountMinimumInflow"},
"accountOverdraftLimit": {"@id": "schema:accountOverdraftLimit"},
"accountablePerson": {"@id": "schema:accountablePerson"},
"acquireLicensePage": {"@id": "schema:acquireLicensePage", "@type": "@id"},
"acquiredFrom": {"@id": "schema:acquiredFrom"},
"acrissCode": {"@id": "schema:acrissCode"},
"actionAccessibilityRequirement": {"@id": "schema:actionAccessibilityRequirement"},
"actionApplication": {"@id": "schema:actionApplication"},
"actionOption": {"@id": "schema:actionOption"},
"actionPlatform": {"@id": "schema:actionPlatform"},
"actionStatus": {"@id": "schema:actionStatus"},
"actionableFeedbackPolicy": {
"@id": "schema:actionableFeedbackPolicy",
"@type": "@id",
},
"activeIngredient": {"@id": "schema:activeIngredient"},
"activityDuration": {"@id": "schema:activityDuration"},
"activityFrequency": {"@id": "schema:activityFrequency"},
"actor": {"@id": "schema:actor"},
"actors": {"@id": "schema:actors"},
"addOn": {"@id": "schema:addOn"},
"additionalName": {"@id": "schema:additionalName"},
"additionalNumberOfGuests": {"@id": "schema:additionalNumberOfGuests"},
"additionalProperty": {"@id": "schema:additionalProperty"},
"additionalType": {"@id": "schema:additionalType", "@type": "@id"},
"additionalVariable": {"@id": "schema:additionalVariable"},
"address": {"@id": "schema:address"},
"addressCountry": {"@id": "schema:addressCountry"},
"addressLocality": {"@id": "schema:addressLocality"},
"addressRegion": {"@id": "schema:addressRegion"},
"administrationRoute": {"@id": "schema:administrationRoute"},
"advanceBookingRequirement": {"@id": "schema:advanceBookingRequirement"},
"adverseOutcome": {"@id": "schema:adverseOutcome"},
"affectedBy": {"@id": "schema:affectedBy"},
"affiliation": {"@id": "schema:affiliation"},
"afterMedia": {"@id": "schema:afterMedia", "@type": "@id"},
"agent": {"@id": "schema:agent"},
"aggregateRating": {"@id": "schema:aggregateRating"},
"aircraft": {"@id": "schema:aircraft"},
"album": {"@id": "schema:album"},
"albumProductionType": {"@id": "schema:albumProductionType"},
"albumRelease": {"@id": "schema:albumRelease"},
"albumReleaseType": {"@id": "schema:albumReleaseType"},
"albums": {"@id": "schema:albums"},
"alcoholWarning": {"@id": "schema:alcoholWarning"},
"algorithm": {"@id": "schema:algorithm"},
"alignmentType": {"@id": "schema:alignmentType"},
"alternateName": {"@id": "schema:alternateName"},
"alternativeHeadline": {"@id": "schema:alternativeHeadline"},
"alumni": {"@id": "schema:alumni"},
"alumniOf": {"@id": "schema:alumniOf"},
"amenityFeature": {"@id": "schema:amenityFeature"},
"amount": {"@id": "schema:amount"},
"amountOfThisGood": {"@id": "schema:amountOfThisGood"},
"annualPercentageRate": {"@id": "schema:annualPercentageRate"},
"answerCount": {"@id": "schema:answerCount"},
"antagonist": {"@id": "schema:antagonist"},
"appearance": {"@id": "schema:appearance"},
"applicableLocation": {"@id": "schema:applicableLocation"},
"applicantLocationRequirements": {"@id": "schema:applicantLocationRequirements"},
"application": {"@id": "schema:application"},
"applicationCategory": {"@id": "schema:applicationCategory"},
"applicationContact": {"@id": "schema:applicationContact"},
"applicationDeadline": {"@id": "schema:applicationDeadline", "@type": "Date"},
"applicationStartDate": {"@id": "schema:applicationStartDate", "@type": "Date"},
"applicationSubCategory": {"@id": "schema:applicationSubCategory"},
"applicationSuite": {"@id": "schema:applicationSuite"},
"appliesToDeliveryMethod": {"@id": "schema:appliesToDeliveryMethod"},
"appliesToPaymentMethod": {"@id": "schema:appliesToPaymentMethod"},
"archiveHeld": {"@id": "schema:archiveHeld"},
"area": {"@id": "schema:area"},
"areaServed": {"@id": "schema:areaServed"},
"arrivalAirport": {"@id": "schema:arrivalAirport"},
"arrivalBusStop": {"@id": "schema:arrivalBusStop"},
"arrivalGate": {"@id": "schema:arrivalGate"},
"arrivalPlatform": {"@id": "schema:arrivalPlatform"},
"arrivalStation": {"@id": "schema:arrivalStation"},
"arrivalTerminal": {"@id": "schema:arrivalTerminal"},
"arrivalTime": {"@id": "schema:arrivalTime", "@type": "DateTime"},
"artEdition": {"@id": "schema:artEdition"},
"artMedium": {"@id": "schema:artMedium"},
"arterialBranch": {"@id": "schema:arterialBranch"},
"artform": {"@id": "schema:artform"},
"articleBody": {"@id": "schema:articleBody"},
"articleSection": {"@id": "schema:articleSection"},
"artist": {"@id": "schema:artist"},
"artworkSurface": {"@id": "schema:artworkSurface"},
"aspect": {"@id": "schema:aspect"},
"assembly": {"@id": "schema:assembly"},
"assemblyVersion": {"@id": "schema:assemblyVersion"},
"associatedAnatomy": {"@id": "schema:associatedAnatomy"},
"associatedArticle": {"@id": "schema:associatedArticle"},
"associatedMedia": {"@id": "schema:associatedMedia"},
"associatedPathophysiology": {"@id": "schema:associatedPathophysiology"},
"athlete": {"@id": "schema:athlete"},
"attendee": {"@id": "schema:attendee"},
"attendees": {"@id": "schema:attendees"},
"audience": {"@id": "schema:audience"},
"audienceType": {"@id": "schema:audienceType"},
"audio": {"@id": "schema:audio"},
"authenticator": {"@id": "schema:authenticator"},
"author": {"@id": "schema:author"},
"availability": {"@id": "schema:availability"},
"availabilityEnds": {"@id": "schema:availabilityEnds", "@type": "Date"},
"availabilityStarts": {"@id": "schema:availabilityStarts", "@type": "Date"},
"availableAtOrFrom": {"@id": "schema:availableAtOrFrom"},
"availableChannel": {"@id": "schema:availableChannel"},
"availableDeliveryMethod": {"@id": "schema:availableDeliveryMethod"},
"availableFrom": {"@id": "schema:availableFrom", "@type": "DateTime"},
"availableIn": {"@id": "schema:availableIn"},
"availableLanguage": {"@id": "schema:availableLanguage"},
"availableOnDevice": {"@id": "schema:availableOnDevice"},
"availableService": {"@id": "schema:availableService"},
"availableStrength": {"@id": "schema:availableStrength"},
"availableTest": {"@id": "schema:availableTest"},
"availableThrough": {"@id": "schema:availableThrough", "@type": "DateTime"},
"award": {"@id": "schema:award"},
"awards": {"@id": "schema:awards"},
"awayTeam": {"@id": "schema:awayTeam"},
"backstory": {"@id": "schema:backstory"},
"bankAccountType": {"@id": "schema:bankAccountType"},
"baseSalary": {"@id": "schema:baseSalary"},
"bccRecipient": {"@id": "schema:bccRecipient"},
"bed": {"@id": "schema:bed"},
"beforeMedia": {"@id": "schema:beforeMedia", "@type": "@id"},
"beneficiaryBank": {"@id": "schema:beneficiaryBank"},
"benefits": {"@id": "schema:benefits"},
"benefitsSummaryUrl": {"@id": "schema:benefitsSummaryUrl", "@type": "@id"},
"bestRating": {"@id": "schema:bestRating"},
"billingAddress": {"@id": "schema:billingAddress"},
"billingIncrement": {"@id": "schema:billingIncrement"},
"billingPeriod": {"@id": "schema:billingPeriod"},
"biomechnicalClass": {"@id": "schema:biomechnicalClass"},
"birthDate": {"@id": "schema:birthDate", "@type": "Date"},
"birthPlace": {"@id": "schema:birthPlace"},
"bitrate": {"@id": "schema:bitrate"},
"blogPost": {"@id": "schema:blogPost"},
"blogPosts": {"@id": "schema:blogPosts"},
"bloodSupply": {"@id": "schema:bloodSupply"},
"boardingGroup": {"@id": "schema:boardingGroup"},
"boardingPolicy": {"@id": "schema:boardingPolicy"},
"bodyLocation": {"@id": "schema:bodyLocation"},
"bodyType": {"@id": "schema:bodyType"},
"bookEdition": {"@id": "schema:bookEdition"},
"bookFormat": {"@id": "schema:bookFormat"},
"bookingAgent": {"@id": "schema:bookingAgent"},
"bookingTime": {"@id": "schema:bookingTime", "@type": "DateTime"},
"borrower": {"@id": "schema:borrower"},
"box": {"@id": "schema:box"},
"branch": {"@id": "schema:branch"},
"branchCode": {"@id": "schema:branchCode"},
"branchOf": {"@id": "schema:branchOf"},
"brand": {"@id": "schema:brand"},
"breadcrumb": {"@id": "schema:breadcrumb"},
"breastfeedingWarning": {"@id": "schema:breastfeedingWarning"},
"broadcastAffiliateOf": {"@id": "schema:broadcastAffiliateOf"},
"broadcastChannelId": {"@id": "schema:broadcastChannelId"},
"broadcastDisplayName": {"@id": "schema:broadcastDisplayName"},
"broadcastFrequency": {"@id": "schema:broadcastFrequency"},
"broadcastFrequencyValue": {"@id": "schema:broadcastFrequencyValue"},
"broadcastOfEvent": {"@id": "schema:broadcastOfEvent"},
"broadcastServiceTier": {"@id": "schema:broadcastServiceTier"},
"broadcastSignalModulation": {"@id": "schema:broadcastSignalModulation"},
"broadcastSubChannel": {"@id": "schema:broadcastSubChannel"},
"broadcastTimezone": {"@id": "schema:broadcastTimezone"},
"broadcaster": {"@id": "schema:broadcaster"},
"broker": {"@id": "schema:broker"},
"browserRequirements": {"@id": "schema:browserRequirements"},
"busName": {"@id": "schema:busName"},
"busNumber": {"@id": "schema:busNumber"},
"businessFunction": {"@id": "schema:businessFunction"},
"buyer": {"@id": "schema:buyer"},
"byArtist": {"@id": "schema:byArtist"},
"byDay": {"@id": "schema:byDay"},
"byMonth": {"@id": "schema:byMonth"},
"byMonthDay": {"@id": "schema:byMonthDay"},
"callSign": {"@id": "schema:callSign"},
"calories": {"@id": "schema:calories"},
"candidate": {"@id": "schema:candidate"},
"caption": {"@id": "schema:caption"},
"carbohydrateContent": {"@id": "schema:carbohydrateContent"},
"cargoVolume": {"@id": "schema:cargoVolume"},
"carrier": {"@id": "schema:carrier"},
"carrierRequirements": {"@id": "schema:carrierRequirements"},
"cashBack": {"@id": "schema:cashBack"},
"catalog": {"@id": "schema:catalog"},
"catalogNumber": {"@id": "schema:catalogNumber"},
"category": {"@id": "schema:category"},
"causeOf": {"@id": "schema:causeOf"},
"ccRecipient": {"@id": "schema:ccRecipient"},
"character": {"@id": "schema:character"},
"characterAttribute": {"@id": "schema:characterAttribute"},
"characterName": {"@id": "schema:characterName"},
"cheatCode": {"@id": "schema:cheatCode"},
"checkinTime": {"@id": "schema:checkinTime", "@type": "DateTime"},
"checkoutTime": {"@id": "schema:checkoutTime", "@type": "DateTime"},
"childMaxAge": {"@id": "schema:childMaxAge"},
"childMinAge": {"@id": "schema:childMinAge"},
"children": {"@id": "schema:children"},
"cholesterolContent": {"@id": "schema:cholesterolContent"},
"circle": {"@id": "schema:circle"},
"citation": {"@id": "schema:citation"},
"claimReviewed": {"@id": "schema:claimReviewed"},
"clincalPharmacology": {"@id": "schema:clincalPharmacology"},
"clinicalPharmacology": {"@id": "schema:clinicalPharmacology"},
"clipNumber": {"@id": "schema:clipNumber"},
"closes": {"@id": "schema:closes"},
"coach": {"@id": "schema:coach"},
"code": {"@id": "schema:code"},
"codeRepository": {"@id": "schema:codeRepository", "@type": "@id"},
"codeSampleType": {"@id": "schema:codeSampleType"},
"codeValue": {"@id": "schema:codeValue"},
"codingSystem": {"@id": "schema:codingSystem"},
"colleague": {"@id": "schema:colleague", "@type": "@id"},
"colleagues": {"@id": "schema:colleagues"},
"collection": {"@id": "schema:collection"},
"collectionSize": {"@id": "schema:collectionSize"},
"color": {"@id": "schema:color"},
"colorist": {"@id": "schema:colorist"},
"comment": {"@id": "schema:comment"},
"commentCount": {"@id": "schema:commentCount"},
"commentText": {"@id": "schema:commentText"},
"commentTime": {"@id": "schema:commentTime", "@type": "Date"},
"competencyRequired": {"@id": "schema:competencyRequired"},
"competitor": {"@id": "schema:competitor"},
"composer": {"@id": "schema:composer"},
"comprisedOf": {"@id": "schema:comprisedOf"},
"conditionsOfAccess": {"@id": "schema:conditionsOfAccess"},
"confirmationNumber": {"@id": "schema:confirmationNumber"},
"connectedTo": {"@id": "schema:connectedTo"},
"constrainingProperty": {"@id": "schema:constrainingProperty"},
"contactOption": {"@id": "schema:contactOption"},
"contactPoint": {"@id": "schema:contactPoint"},
"contactPoints": {"@id": "schema:contactPoints"},
"contactType": {"@id": "schema:contactType"},
"contactlessPayment": {"@id": "schema:contactlessPayment"},
"containedIn": {"@id": "schema:containedIn"},
"containedInPlace": {"@id": "schema:containedInPlace"},
"containsPlace": {"@id": "schema:containsPlace"},
"containsSeason": {"@id": "schema:containsSeason"},
"contentLocation": {"@id": "schema:contentLocation"},
"contentRating": {"@id": "schema:contentRating"},
"contentReferenceTime": {
"@id": "schema:contentReferenceTime",
"@type": "DateTime",
},
"contentSize": {"@id": "schema:contentSize"},
"contentType": {"@id": "schema:contentType"},
"contentUrl": {"@id": "schema:contentUrl", "@type": "@id"},
"contraindication": {"@id": "schema:contraindication"},
"contributor": {"@id": "schema:contributor"},
"cookTime": {"@id": "schema:cookTime"},
"cookingMethod": {"@id": "schema:cookingMethod"},
"copyrightHolder": {"@id": "schema:copyrightHolder"},
"copyrightYear": {"@id": "schema:copyrightYear"},
"correction": {"@id": "schema:correction"},
"correctionsPolicy": {"@id": "schema:correctionsPolicy", "@type": "@id"},
"costCategory": {"@id": "schema:costCategory"},
"costCurrency": {"@id": "schema:costCurrency"},
"costOrigin": {"@id": "schema:costOrigin"},
"costPerUnit": {"@id": "schema:costPerUnit"},
"countriesNotSupported": {"@id": "schema:countriesNotSupported"},
"countriesSupported": {"@id": "schema:countriesSupported"},
"countryOfOrigin": {"@id": "schema:countryOfOrigin"},
"course": {"@id": "schema:course"},
"courseCode": {"@id": "schema:courseCode"},
"courseMode": {"@id": "schema:courseMode"},
"coursePrerequisites": {"@id": "schema:coursePrerequisites"},
"courseWorkload": {"@id": "schema:courseWorkload"},
"coverageEndTime": {"@id": "schema:coverageEndTime", "@type": "DateTime"},
"coverageStartTime": {"@id": "schema:coverageStartTime", "@type": "DateTime"},
"creativeWorkStatus": {"@id": "schema:creativeWorkStatus"},
"creator": {"@id": "schema:creator"},
"credentialCategory": {"@id": "schema:credentialCategory"},
"creditedTo": {"@id": "schema:creditedTo"},
"cssSelector": {"@id": "schema:cssSelector"},
"currenciesAccepted": {"@id": "schema:currenciesAccepted"},
"currency": {"@id": "schema:currency"},
"currentExchangeRate": {"@id": "schema:currentExchangeRate"},
"customer": {"@id": "schema:customer"},
"dataFeedElement": {"@id": "schema:dataFeedElement"},
"dataset": {"@id": "schema:dataset"},
"datasetTimeInterval": {"@id": "schema:datasetTimeInterval", "@type": "DateTime"},
"dateCreated": {"@id": "schema:dateCreated", "@type": "Date"},
"dateDeleted": {"@id": "schema:dateDeleted", "@type": "Date"},
"dateIssued": {"@id": "schema:dateIssued", "@type": "Date"},
"dateModified": {"@id": "schema:dateModified", "@type": "Date"},
"datePosted": {"@id": "schema:datePosted", "@type": "Date"},
"datePublished": {"@id": "schema:datePublished", "@type": "Date"},
"dateRead": {"@id": "schema:dateRead", "@type": "Date"},
"dateReceived": {"@id": "schema:dateReceived", "@type": "DateTime"},
"dateSent": {"@id": "schema:dateSent", "@type": "DateTime"},
"dateVehicleFirstRegistered": {
"@id": "schema:dateVehicleFirstRegistered",
"@type": "Date",
},
"dateline": {"@id": "schema:dateline"},
"dayOfWeek": {"@id": "schema:dayOfWeek"},
"deathDate": {"@id": "schema:deathDate", "@type": "Date"},
"deathPlace": {"@id": "schema:deathPlace"},
"defaultValue": {"@id": "schema:defaultValue"},
"deliveryAddress": {"@id": "schema:deliveryAddress"},
"deliveryLeadTime": {"@id": "schema:deliveryLeadTime"},
"deliveryMethod": {"@id": "schema:deliveryMethod"},
"deliveryStatus": {"@id": "schema:deliveryStatus"},
"department": {"@id": "schema:department"},
"departureAirport": {"@id": "schema:departureAirport"},
"departureBusStop": {"@id": "schema:departureBusStop"},
"departureGate": {"@id": "schema:departureGate"},
"departurePlatform": {"@id": "schema:departurePlatform"},
"departureStation": {"@id": "schema:departureStation"},
"departureTerminal": {"@id": "schema:departureTerminal"},
"departureTime": {"@id": "schema:departureTime", "@type": "DateTime"},
"dependencies": {"@id": "schema:dependencies"},
"depth": {"@id": "schema:depth"},
"description": {"@id": "schema:description"},
"device": {"@id": "schema:device"},
"diagnosis": {"@id": "schema:diagnosis"},
"diagram": {"@id": "schema:diagram"},
"diet": {"@id": "schema:diet"},
"dietFeatures": {"@id": "schema:dietFeatures"},
"differentialDiagnosis": {"@id": "schema:differentialDiagnosis"},
"director": {"@id": "schema:director"},
"directors": {"@id": "schema:directors"},
"disambiguatingDescription": {"@id": "schema:disambiguatingDescription"},
"discount": {"@id": "schema:discount"},
"discountCode": {"@id": "schema:discountCode"},
"discountCurrency": {"@id": "schema:discountCurrency"},
"discusses": {"@id": "schema:discusses"},
"discussionUrl": {"@id": "schema:discussionUrl", "@type": "@id"},
"diseasePreventionInfo": {"@id": "schema:diseasePreventionInfo", "@type": "@id"},
"diseaseSpreadStatistics": {
"@id": "schema:diseaseSpreadStatistics",
"@type": "@id",
},
"dissolutionDate": {"@id": "schema:dissolutionDate", "@type": "Date"},
"distance": {"@id": "schema:distance"},
"distinguishingSign": {"@id": "schema:distinguishingSign"},
"distribution": {"@id": "schema:distribution"},
"diversityPolicy": {"@id": "schema:diversityPolicy", "@type": "@id"},
"diversityStaffingReport": {
"@id": "schema:diversityStaffingReport",
"@type": "@id",
},
"documentation": {"@id": "schema:documentation", "@type": "@id"},
"domainIncludes": {"@id": "schema:domainIncludes"},
"domiciledMortgage": {"@id": "schema:domiciledMortgage"},
"doorTime": {"@id": "schema:doorTime", "@type": "DateTime"},
"dosageForm": {"@id": "schema:dosageForm"},
"doseSchedule": {"@id": "schema:doseSchedule"},
"doseUnit": {"@id": "schema:doseUnit"},
"doseValue": {"@id": "schema:doseValue"},
"downPayment": {"@id": "schema:downPayment"},
"downloadUrl": {"@id": "schema:downloadUrl", "@type": "@id"},
"downvoteCount": {"@id": "schema:downvoteCount"},
"drainsTo": {"@id": "schema:drainsTo"},
"driveWheelConfiguration": {"@id": "schema:driveWheelConfiguration"},
"dropoffLocation": {"@id": "schema:dropoffLocation"},
"dropoffTime": {"@id": "schema:dropoffTime", "@type": "DateTime"},
"drug": {"@id": "schema:drug"},
"drugClass": {"@id": "schema:drugClass"},
"drugUnit": {"@id": "schema:drugUnit"},
"duns": {"@id": "schema:duns"},
"duplicateTherapy": {"@id": "schema:duplicateTherapy"},
"duration": {"@id": "schema:duration"},
"durationOfWarranty": {"@id": "schema:durationOfWarranty"},
"duringMedia": {"@id": "schema:duringMedia", "@type": "@id"},
"earlyPrepaymentPenalty": {"@id": "schema:earlyPrepaymentPenalty"},
"editor": {"@id": "schema:editor"},
"educationRequirements": {"@id": "schema:educationRequirements"},
"educationalAlignment": {"@id": "schema:educationalAlignment"},
"educationalCredentialAwarded": {"@id": "schema:educationalCredentialAwarded"},
"educationalFramework": {"@id": "schema:educationalFramework"},
"educationalLevel": {"@id": "schema:educationalLevel"},
"educationalProgramMode": {"@id": "schema:educationalProgramMode"},
"educationalRole": {"@id": "schema:educationalRole"},
"educationalUse": {"@id": "schema:educationalUse"},
"elevation": {"@id": "schema:elevation"},
"eligibleCustomerType": {"@id": "schema:eligibleCustomerType"},
"eligibleDuration": {"@id": "schema:eligibleDuration"},
"eligibleQuantity": {"@id": "schema:eligibleQuantity"},
"eligibleRegion": {"@id": "schema:eligibleRegion"},
"eligibleTransactionVolume": {"@id": "schema:eligibleTransactionVolume"},
"email": {"@id": "schema:email"},
"embedUrl": {"@id": "schema:embedUrl", "@type": "@id"},
"emissionsCO2": {"@id": "schema:emissionsCO2"},
"employee": {"@id": "schema:employee"},
"employees": {"@id": "schema:employees"},
"employerOverview": {"@id": "schema:employerOverview"},
"employmentType": {"@id": "schema:employmentType"},
"employmentUnit": {"@id": "schema:employmentUnit"},
"encodesCreativeWork": {"@id": "schema:encodesCreativeWork"},
"encoding": {"@id": "schema:encoding"},
"encodingFormat": {"@id": "schema:encodingFormat"},
"encodingType": {"@id": "schema:encodingType"},
"encodings": {"@id": "schema:encodings"},
"endDate": {"@id": "schema:endDate", "@type": "Date"},
"endOffset": {"@id": "schema:endOffset"},
"endTime": {"@id": "schema:endTime", "@type": "DateTime"},
"endorsee": {"@id": "schema:endorsee"},
"endorsers": {"@id": "schema:endorsers"},
"engineDisplacement": {"@id": "schema:engineDisplacement"},
"enginePower": {"@id": "schema:enginePower"},
"engineType": {"@id": "schema:engineType"},
"entertainmentBusiness": {"@id": "schema:entertainmentBusiness"},
"epidemiology": {"@id": "schema:epidemiology"},
"episode": {"@id": "schema:episode"},
"episodeNumber": {"@id": "schema:episodeNumber"},
"episodes": {"@id": "schema:episodes"},
"equal": {"@id": "schema:equal"},
"error": {"@id": "schema:error"},
"estimatedCost": {"@id": "schema:estimatedCost"},
"estimatedFlightDuration": {"@id": "schema:estimatedFlightDuration"},
"estimatedSalary": {"@id": "schema:estimatedSalary"},
"estimatesRiskOf": {"@id": "schema:estimatesRiskOf"},
"ethicsPolicy": {"@id": "schema:ethicsPolicy", "@type": "@id"},
"event": {"@id": "schema:event"},
"eventAttendanceMode": {"@id": "schema:eventAttendanceMode"},
"eventSchedule": {"@id": "schema:eventSchedule"},
"eventStatus": {"@id": "schema:eventStatus"},
"events": {"@id": "schema:events"},
"evidenceLevel": {"@id": "schema:evidenceLevel"},
"evidenceOrigin": {"@id": "schema:evidenceOrigin"},
"exampleOfWork": {"@id": "schema:exampleOfWork"},
"exceptDate": {"@id": "schema:exceptDate", "@type": "Date"},
"exchangeRateSpread": {"@id": "schema:exchangeRateSpread"},
"executableLibraryName": {"@id": "schema:executableLibraryName"},
"exerciseCourse": {"@id": "schema:exerciseCourse"},
"exercisePlan": {"@id": "schema:exercisePlan"},
"exerciseRelatedDiet": {"@id": "schema:exerciseRelatedDiet"},
"exerciseType": {"@id": "schema:exerciseType"},
"exifData": {"@id": "schema:exifData"},
"expectedArrivalFrom": {"@id": "schema:expectedArrivalFrom", "@type": "Date"},
"expectedArrivalUntil": {"@id": "schema:expectedArrivalUntil", "@type": "Date"},
"expectedPrognosis": {"@id": "schema:expectedPrognosis"},
"expectsAcceptanceOf": {"@id": "schema:expectsAcceptanceOf"},
"experienceRequirements": {"@id": "schema:experienceRequirements"},
"expertConsiderations": {"@id": "schema:expertConsiderations"},
"expires": {"@id": "schema:expires", "@type": "Date"},
"familyName": {"@id": "schema:familyName"},
"fatContent": {"@id": "schema:fatContent"},
"faxNumber": {"@id": "schema:faxNumber"},
"featureList": {"@id": "schema:featureList"},
"feesAndCommissionsSpecification": {
"@id": "schema:feesAndCommissionsSpecification"
},
"fiberContent": {"@id": "schema:fiberContent"},
"fileFormat": {"@id": "schema:fileFormat"},
"fileSize": {"@id": "schema:fileSize"},
"financialAidEligible": {"@id": "schema:financialAidEligible"},
"firstAppearance": {"@id": "schema:firstAppearance"},
"firstPerformance": {"@id": "schema:firstPerformance"},
"flightDistance": {"@id": "schema:flightDistance"},
"flightNumber": {"@id": "schema:flightNumber"},
"floorLevel": {"@id": "schema:floorLevel"},
"floorLimit": {"@id": "schema:floorLimit"},
"floorSize": {"@id": "schema:floorSize"},
"followee": {"@id": "schema:followee"},
"follows": {"@id": "schema:follows"},
"followup": {"@id": "schema:followup"},
"foodEstablishment": {"@id": "schema:foodEstablishment"},
"foodEvent": {"@id": "schema:foodEvent"},
"foodWarning": {"@id": "schema:foodWarning"},
"founder": {"@id": "schema:founder"},
"founders": {"@id": "schema:founders"},
"foundingDate": {"@id": "schema:foundingDate", "@type": "Date"},
"foundingLocation": {"@id": "schema:foundingLocation"},
"free": {"@id": "schema:free"},
"frequency": {"@id": "schema:frequency"},
"fromLocation": {"@id": "schema:fromLocation"},
"fuelCapacity": {"@id": "schema:fuelCapacity"},
"fuelConsumption": {"@id": "schema:fuelConsumption"},
"fuelEfficiency": {"@id": "schema:fuelEfficiency"},
"fuelType": {"@id": "schema:fuelType"},
"functionalClass": {"@id": "schema:functionalClass"},
"fundedItem": {"@id": "schema:fundedItem"},
"funder": {"@id": "schema:funder"},
"game": {"@id": "schema:game"},
"gameItem": {"@id": "schema:gameItem"},
"gameLocation": {"@id": "schema:gameLocation", "@type": "@id"},
"gamePlatform": {"@id": "schema:gamePlatform"},
"gameServer": {"@id": "schema:gameServer"},
"gameTip": {"@id": "schema:gameTip"},
"gender": {"@id": "schema:gender"},
"genre": {"@id": "schema:genre"},
"geo": {"@id": "schema:geo"},
"geoContains": {"@id": "schema:geoContains"},
"geoCoveredBy": {"@id": "schema:geoCoveredBy"},
"geoCovers": {"@id": "schema:geoCovers"},
"geoCrosses": {"@id": "schema:geoCrosses"},
"geoDisjoint": {"@id": "schema:geoDisjoint"},
"geoEquals": {"@id": "schema:geoEquals"},
"geoIntersects": {"@id": "schema:geoIntersects"},
"geoMidpoint": {"@id": "schema:geoMidpoint"},
"geoOverlaps": {"@id": "schema:geoOverlaps"},
"geoRadius": {"@id": "schema:geoRadius"},
"geoTouches": {"@id": "schema:geoTouches"},
"geoWithin": {"@id": "schema:geoWithin"},
"geographicArea": {"@id": "schema:geographicArea"},
"gettingTestedInfo": {"@id": "schema:gettingTestedInfo", "@type": "@id"},
"givenName": {"@id": "schema:givenName"},
"globalLocationNumber": {"@id": "schema:globalLocationNumber"},
"gracePeriod": {"@id": "schema:gracePeriod"},
"grantee": {"@id": "schema:grantee"},
"greater": {"@id": "schema:greater"},
"greaterOrEqual": {"@id": "schema:greaterOrEqual"},
"gtin": {"@id": "schema:gtin"},
"gtin12": {"@id": "schema:gtin12"},
"gtin13": {"@id": "schema:gtin13"},
"gtin14": {"@id": "schema:gtin14"},
"gtin8": {"@id": "schema:gtin8"},
"guideline": {"@id": "schema:guideline"},
"guidelineDate": {"@id": "schema:guidelineDate", "@type": "Date"},
"guidelineSubject": {"@id": "schema:guidelineSubject"},
"hasBroadcastChannel": {"@id": "schema:hasBroadcastChannel"},
"hasCategoryCode": {"@id": "schema:hasCategoryCode"},
"hasCourseInstance": {"@id": "schema:hasCourseInstance"},
"hasCredential": {"@id": "schema:hasCredential"},
"hasDefinedTerm": {"@id": "schema:hasDefinedTerm"},
"hasDeliveryMethod": {"@id": "schema:hasDeliveryMethod"},
"hasDigitalDocumentPermission": {"@id": "schema:hasDigitalDocumentPermission"},
"hasDriveThroughService": {"@id": "schema:hasDriveThroughService"},
"hasHealthAspect": {"@id": "schema:hasHealthAspect"},
"hasMap": {"@id": "schema:hasMap", "@type": "@id"},
"hasMenu": {"@id": "schema:hasMenu"},
"hasMenuItem": {"@id": "schema:hasMenuItem"},
"hasMenuSection": {"@id": "schema:hasMenuSection"},
"hasMerchantReturnPolicy": {"@id": "schema:hasMerchantReturnPolicy"},
"hasOccupation": {"@id": "schema:hasOccupation"},
"hasOfferCatalog": {"@id": "schema:hasOfferCatalog"},
"hasPOS": {"@id": "schema:hasPOS"},
"hasPart": {"@id": "schema:hasPart"},
"hasProductReturnPolicy": {"@id": "schema:hasProductReturnPolicy"},
"headline": {"@id": "schema:headline"},
"healthCondition": {"@id": "schema:healthCondition"},
"healthPlanCoinsuranceOption": {"@id": "schema:healthPlanCoinsuranceOption"},
"healthPlanCoinsuranceRate": {"@id": "schema:healthPlanCoinsuranceRate"},
"healthPlanCopay": {"@id": "schema:healthPlanCopay"},
"healthPlanCopayOption": {"@id": "schema:healthPlanCopayOption"},
"healthPlanCostSharing": {"@id": "schema:healthPlanCostSharing"},
"healthPlanDrugOption": {"@id": "schema:healthPlanDrugOption"},
"healthPlanDrugTier": {"@id": "schema:healthPlanDrugTier"},
"healthPlanId": {"@id": "schema:healthPlanId"},
"healthPlanMarketingUrl": {"@id": "schema:healthPlanMarketingUrl", "@type": "@id"},
"healthPlanNetworkId": {"@id": "schema:healthPlanNetworkId"},
"healthPlanNetworkTier": {"@id": "schema:healthPlanNetworkTier"},
"healthPlanPharmacyCategory": {"@id": "schema:healthPlanPharmacyCategory"},
"height": {"@id": "schema:height"},
"highPrice": {"@id": "schema:highPrice"},
"hiringOrganization": {"@id": "schema:hiringOrganization"},
"holdingArchive": {"@id": "schema:holdingArchive"},
"homeLocation": {"@id": "schema:homeLocation"},
"homeTeam": {"@id": "schema:homeTeam"},
"honorificPrefix": {"@id": "schema:honorificPrefix"},
"honorificSuffix": {"@id": "schema:honorificSuffix"},
"hospitalAffiliation": {"@id": "schema:hospitalAffiliation"},
"hostingOrganization": {"@id": "schema:hostingOrganization"},
"hoursAvailable": {"@id": "schema:hoursAvailable"},
"howPerformed": {"@id": "schema:howPerformed"},
"iataCode": {"@id": "schema:iataCode"},
"icaoCode": {"@id": "schema:icaoCode"},
"identifier": {"@id": "schema:identifier"},
"identifyingExam": {"@id": "schema:identifyingExam"},
"identifyingTest": {"@id": "schema:identifyingTest"},
"illustrator": {"@id": "schema:illustrator"},
"image": {"@id": "schema:image", "@type": "@id"},
"imagingTechnique": {"@id": "schema:imagingTechnique"},
"inAlbum": {"@id": "schema:inAlbum"},
"inBroadcastLineup": {"@id": "schema:inBroadcastLineup"},
"inCodeSet": {"@id": "schema:inCodeSet", "@type": "@id"},
"inDefinedTermSet": {"@id": "schema:inDefinedTermSet", "@type": "@id"},
"inLanguage": {"@id": "schema:inLanguage"},
"inPlaylist": {"@id": "schema:inPlaylist"},
"inStoreReturnsOffered": {"@id": "schema:inStoreReturnsOffered"},
"inSupportOf": {"@id": "schema:inSupportOf"},
"incentiveCompensation": {"@id": "schema:incentiveCompensation"},
"incentives": {"@id": "schema:incentives"},
"includedComposition": {"@id": "schema:includedComposition"},
"includedDataCatalog": {"@id": "schema:includedDataCatalog"},
"includedInDataCatalog": {"@id": "schema:includedInDataCatalog"},
"includedInHealthInsurancePlan": {"@id": "schema:includedInHealthInsurancePlan"},
"includedRiskFactor": {"@id": "schema:includedRiskFactor"},
"includesAttraction": {"@id": "schema:includesAttraction"},
"includesHealthPlanFormulary": {"@id": "schema:includesHealthPlanFormulary"},
"includesHealthPlanNetwork": {"@id": "schema:includesHealthPlanNetwork"},
"includesObject": {"@id": "schema:includesObject"},
"increasesRiskOf": {"@id": "schema:increasesRiskOf"},
"industry": {"@id": "schema:industry"},
"ineligibleRegion": {"@id": "schema:ineligibleRegion"},
"infectiousAgent": {"@id": "schema:infectiousAgent"},
"infectiousAgentClass": {"@id": "schema:infectiousAgentClass"},
"ingredients": {"@id": "schema:ingredients"},
"inker": {"@id": "schema:inker"},
"insertion": {"@id": "schema:insertion"},
"installUrl": {"@id": "schema:installUrl", "@type": "@id"},
"instructor": {"@id": "schema:instructor"},
"instrument": {"@id": "schema:instrument"},
"intensity": {"@id": "schema:intensity"},
"interactingDrug": {"@id": "schema:interactingDrug"},
"interactionCount": {"@id": "schema:interactionCount"},
"interactionService": {"@id": "schema:interactionService"},
"interactionStatistic": {"@id": "schema:interactionStatistic"},
"interactionType": {"@id": "schema:interactionType"},
"interactivityType": {"@id": "schema:interactivityType"},
"interestRate": {"@id": "schema:interestRate"},
"inventoryLevel": {"@id": "schema:inventoryLevel"},
"inverseOf": {"@id": "schema:inverseOf"},
"isAcceptingNewPatients": {"@id": "schema:isAcceptingNewPatients"},
"isAccessibleForFree": {"@id": "schema:isAccessibleForFree"},
"isAccessoryOrSparePartFor": {"@id": "schema:isAccessoryOrSparePartFor"},
"isAvailableGenerically": {"@id": "schema:isAvailableGenerically"},
"isBasedOn": {"@id": "schema:isBasedOn", "@type": "@id"},
"isBasedOnUrl": {"@id": "schema:isBasedOnUrl", "@type": "@id"},
"isConsumableFor": {"@id": "schema:isConsumableFor"},
"isFamilyFriendly": {"@id": "schema:isFamilyFriendly"},
"isGift": {"@id": "schema:isGift"},
"isLiveBroadcast": {"@id": "schema:isLiveBroadcast"},
"isPartOf": {"@id": "schema:isPartOf", "@type": "@id"},
"isPlanForApartment": {"@id": "schema:isPlanForApartment"},
"isProprietary": {"@id": "schema:isProprietary"},
"isRelatedTo": {"@id": "schema:isRelatedTo"},
"isResizable": {"@id": "schema:isResizable"},
"isSimilarTo": {"@id": "schema:isSimilarTo"},
"isVariantOf": {"@id": "schema:isVariantOf"},
"isbn": {"@id": "schema:isbn"},
"isicV4": {"@id": "schema:isicV4"},
"isrcCode": {"@id": "schema:isrcCode"},
"issn": {"@id": "schema:issn"},
"issueNumber": {"@id": "schema:issueNumber"},
"issuedBy": {"@id": "schema:issuedBy"},
"issuedThrough": {"@id": "schema:issuedThrough"},
"iswcCode": {"@id": "schema:iswcCode"},
"item": {"@id": "schema:item"},
"itemCondition": {"@id": "schema:itemCondition"},
"itemListElement": {"@id": "schema:itemListElement"},
"itemListOrder": {"@id": "schema:itemListOrder"},
"itemLocation": {"@id": "schema:itemLocation"},
"itemOffered": {"@id": "schema:itemOffered"},
"itemReviewed": {"@id": "schema:itemReviewed"},
"itemShipped": {"@id": "schema:itemShipped"},
"itinerary": {"@id": "schema:itinerary"},
"jobBenefits": {"@id": "schema:jobBenefits"},
"jobImmediateStart": {"@id": "schema:jobImmediateStart"},
"jobLocation": {"@id": "schema:jobLocation"},
"jobLocationType": {"@id": "schema:jobLocationType"},
"jobStartDate": {"@id": "schema:jobStartDate"},
"jobTitle": {"@id": "schema:jobTitle"},
"keywords": {"@id": "schema:keywords"},
"knownVehicleDamages": {"@id": "schema:knownVehicleDamages"},
"knows": {"@id": "schema:knows"},
"knowsAbout": {"@id": "schema:knowsAbout"},
"knowsLanguage": {"@id": "schema:knowsLanguage"},
"labelDetails": {"@id": "schema:labelDetails", "@type": "@id"},
"landlord": {"@id": "schema:landlord"},
"language": {"@id": "schema:language"},
"lastReviewed": {"@id": "schema:lastReviewed", "@type": "Date"},
"latitude": {"@id": "schema:latitude"},
"learningResourceType": {"@id": "schema:learningResourceType"},
"leaseLength": {"@id": "schema:leaseLength"},
"legalName": {"@id": "schema:legalName"},
"legalStatus": {"@id": "schema:legalStatus"},
"legislationApplies": {"@id": "schema:legislationApplies"},
"legislationChanges": {"@id": "schema:legislationChanges"},
"legislationConsolidates": {"@id": "schema:legislationConsolidates"},
"legislationDate": {"@id": "schema:legislationDate", "@type": "Date"},
"legislationDateVersion": {
"@id": "schema:legislationDateVersion",
"@type": "Date",
},
"legislationIdentifier": {"@id": "schema:legislationIdentifier"},
"legislationJurisdiction": {"@id": "schema:legislationJurisdiction"},
"legislationLegalForce": {"@id": "schema:legislationLegalForce"},
"legislationLegalValue": {"@id": "schema:legislationLegalValue"},
"legislationPassedBy": {"@id": "schema:legislationPassedBy"},
"legislationResponsible": {"@id": "schema:legislationResponsible"},
"legislationTransposes": {"@id": "schema:legislationTransposes"},
"legislationType": {"@id": "schema:legislationType"},
"leiCode": {"@id": "schema:leiCode"},
"lender": {"@id": "schema:lender"},
"lesser": {"@id": "schema:lesser"},
"lesserOrEqual": {"@id": "schema:lesserOrEqual"},
"letterer": {"@id": "schema:letterer"},
"license": {"@id": "schema:license", "@type": "@id"},
"line": {"@id": "schema:line"},
"linkRelationship": {"@id": "schema:linkRelationship"},
"liveBlogUpdate": {"@id": "schema:liveBlogUpdate"},
"loanMortgageMandateAmount": {"@id": "schema:loanMortgageMandateAmount"},
"loanPaymentAmount": {"@id": "schema:loanPaymentAmount"},
"loanPaymentFrequency": {"@id": "schema:loanPaymentFrequency"},
"loanRepaymentForm": {"@id": "schema:loanRepaymentForm"},
"loanTerm": {"@id": "schema:loanTerm"},
"loanType": {"@id": "schema:loanType"},
"location": {"@id": "schema:location"},
"locationCreated": {"@id": "schema:locationCreated"},
"lodgingUnitDescription": {"@id": "schema:lodgingUnitDescription"},
"lodgingUnitType": {"@id": "schema:lodgingUnitType"},
"logo": {"@id": "schema:logo", "@type": "@id"},
"longitude": {"@id": "schema:longitude"},
"loser": {"@id": "schema:loser"},
"lowPrice": {"@id": "schema:lowPrice"},
"lyricist": {"@id": "schema:lyricist"},
"lyrics": {"@id": "schema:lyrics"},
"mainContentOfPage": {"@id": "schema:mainContentOfPage"},
"mainEntity": {"@id": "schema:mainEntity"},
"mainEntityOfPage": {"@id": "schema:mainEntityOfPage", "@type": "@id"},
"maintainer": {"@id": "schema:maintainer"},
"makesOffer": {"@id": "schema:makesOffer"},
"manufacturer": {"@id": "schema:manufacturer"},
"map": {"@id": "schema:map", "@type": "@id"},
"mapType": {"@id": "schema:mapType"},
"maps": {"@id": "schema:maps", "@type": "@id"},
"marginOfError": {"@id": "schema:marginOfError", "@type": "DateTime"},
"masthead": {"@id": "schema:masthead", "@type": "@id"},
"material": {"@id": "schema:material"},
"materialExtent": {"@id": "schema:materialExtent"},
"maxPrice": {"@id": "schema:maxPrice"},
"maxValue": {"@id": "schema:maxValue"},
"maximumAttendeeCapacity": {"@id": "schema:maximumAttendeeCapacity"},
"maximumEnrollment": {"@id": "schema:maximumEnrollment"},
"maximumIntake": {"@id": "schema:maximumIntake"},
"maximumPhysicalAttendeeCapacity": {
"@id": "schema:maximumPhysicalAttendeeCapacity"
},
"maximumVirtualAttendeeCapacity": {"@id": "schema:maximumVirtualAttendeeCapacity"},
"mealService": {"@id": "schema:mealService"},
"measuredProperty": {"@id": "schema:measuredProperty"},
"measuredValue": {"@id": "schema:measuredValue"},
"measurementTechnique": {"@id": "schema:measurementTechnique"},
"mechanismOfAction": {"@id": "schema:mechanismOfAction"},
"mediaAuthenticityCategory": {"@id": "schema:mediaAuthenticityCategory"},
"median": {"@id": "schema:median"},
"medicalSpecialty": {"@id": "schema:medicalSpecialty"},
"medicineSystem": {"@id": "schema:medicineSystem"},
"meetsEmissionStandard": {"@id": "schema:meetsEmissionStandard"},
"member": {"@id": "schema:member"},
"memberOf": {"@id": "schema:memberOf"},
"members": {"@id": "schema:members"},
"membershipNumber": {"@id": "schema:membershipNumber"},
"membershipPointsEarned": {"@id": "schema:membershipPointsEarned"},
"memoryRequirements": {"@id": "schema:memoryRequirements"},
"mentions": {"@id": "schema:mentions"},
"menu": {"@id": "schema:menu"},
"menuAddOn": {"@id": "schema:menuAddOn"},
"merchant": {"@id": "schema:merchant"},
"merchantReturnDays": {"@id": "schema:merchantReturnDays"},
"merchantReturnLink": {"@id": "schema:merchantReturnLink", "@type": "@id"},
"messageAttachment": {"@id": "schema:messageAttachment"},
"mileageFromOdometer": {"@id": "schema:mileageFromOdometer"},
"minPrice": {"@id": "schema:minPrice"},
"minValue": {"@id": "schema:minValue"},
"minimumPaymentDue": {"@id": "schema:minimumPaymentDue"},
"missionCoveragePrioritiesPolicy": {
"@id": "schema:missionCoveragePrioritiesPolicy",
"@type": "@id",
},
"model": {"@id": "schema:model"},
"modelDate": {"@id": "schema:modelDate", "@type": "Date"},
"modifiedTime": {"@id": "schema:modifiedTime", "@type": "DateTime"},
"monthlyMinimumRepaymentAmount": {"@id": "schema:monthlyMinimumRepaymentAmount"},
"mpn": {"@id": "schema:mpn"},
"multipleValues": {"@id": "schema:multipleValues"},
"muscleAction": {"@id": "schema:muscleAction"},
"musicArrangement": {"@id": "schema:musicArrangement"},
"musicBy": {"@id": "schema:musicBy"},
"musicCompositionForm": {"@id": "schema:musicCompositionForm"},
"musicGroupMember": {"@id": "schema:musicGroupMember"},
"musicReleaseFormat": {"@id": "schema:musicReleaseFormat"},
"musicalKey": {"@id": "schema:musicalKey"},
"naics": {"@id": "schema:naics"},
"name": {"@id": "schema:name"},
"namedPosition": {"@id": "schema:namedPosition"},
"nationality": {"@id": "schema:nationality"},
"naturalProgression": {"@id": "schema:naturalProgression"},
"nerve": {"@id": "schema:nerve"},
"nerveMotor": {"@id": "schema:nerveMotor"},
"netWorth": {"@id": "schema:netWorth"},
"newsUpdatesAndGuidelines": {
"@id": "schema:newsUpdatesAndGuidelines",
"@type": "@id",
},
"nextItem": {"@id": "schema:nextItem"},
"noBylinesPolicy": {"@id": "schema:noBylinesPolicy", "@type": "@id"},
"nonEqual": {"@id": "schema:nonEqual"},
"nonProprietaryName": {"@id": "schema:nonProprietaryName"},
"normalRange": {"@id": "schema:normalRange"},
"nsn": {"@id": "schema:nsn"},
"numAdults": {"@id": "schema:numAdults"},
"numChildren": {"@id": "schema:numChildren"},
"numConstraints": {"@id": "schema:numConstraints"},
"numTracks": {"@id": "schema:numTracks"},
"numberOfAccommodationUnits": {"@id": "schema:numberOfAccommodationUnits"},
"numberOfAirbags": {"@id": "schema:numberOfAirbags"},
"numberOfAvailableAccommodationUnits": {
"@id": "schema:numberOfAvailableAccommodationUnits"
},
"numberOfAxles": {"@id": "schema:numberOfAxles"},
"numberOfBathroomsTotal": {"@id": "schema:numberOfBathroomsTotal"},
"numberOfBedrooms": {"@id": "schema:numberOfBedrooms"},
"numberOfBeds": {"@id": "schema:numberOfBeds"},
"numberOfCredits": {"@id": "schema:numberOfCredits"},
"numberOfDoors": {"@id": "schema:numberOfDoors"},
"numberOfEmployees": {"@id": "schema:numberOfEmployees"},
"numberOfEpisodes": {"@id": "schema:numberOfEpisodes"},
"numberOfForwardGears": {"@id": "schema:numberOfForwardGears"},
"numberOfFullBathrooms": {"@id": "schema:numberOfFullBathrooms"},
"numberOfItems": {"@id": "schema:numberOfItems"},
"numberOfLoanPayments": {"@id": "schema:numberOfLoanPayments"},
"numberOfPages": {"@id": "schema:numberOfPages"},
"numberOfPartialBathrooms": {"@id": "schema:numberOfPartialBathrooms"},
"numberOfPlayers": {"@id": "schema:numberOfPlayers"},
"numberOfPreviousOwners": {"@id": "schema:numberOfPreviousOwners"},
"numberOfRooms": {"@id": "schema:numberOfRooms"},
"numberOfSeasons": {"@id": "schema:numberOfSeasons"},
"numberedPosition": {"@id": "schema:numberedPosition"},
"nutrition": {"@id": "schema:nutrition"},
"object": {"@id": "schema:object"},
"observationDate": {"@id": "schema:observationDate", "@type": "DateTime"},
"observedNode": {"@id": "schema:observedNode"},
"occupancy": {"@id": "schema:occupancy"},
"occupationLocation": {"@id": "schema:occupationLocation"},
"occupationalCategory": {"@id": "schema:occupationalCategory"},
"occupationalCredentialAwarded": {"@id": "schema:occupationalCredentialAwarded"},
"offerCount": {"@id": "schema:offerCount"},
"offeredBy": {"@id": "schema:offeredBy"},
"offers": {"@id": "schema:offers"},
"offersPrescriptionByMail": {"@id": "schema:offersPrescriptionByMail"},
"openingHours": {"@id": "schema:openingHours"},
"openingHoursSpecification": {"@id": "schema:openingHoursSpecification"},
"opens": {"@id": "schema:opens"},
"operatingSystem": {"@id": "schema:operatingSystem"},
"opponent": {"@id": "schema:opponent"},
"option": {"@id": "schema:option"},
"orderDate": {"@id": "schema:orderDate", "@type": "Date"},
"orderDelivery": {"@id": "schema:orderDelivery"},
"orderItemNumber": {"@id": "schema:orderItemNumber"},
"orderItemStatus": {"@id": "schema:orderItemStatus"},
"orderNumber": {"@id": "schema:orderNumber"},
"orderQuantity": {"@id": "schema:orderQuantity"},
"orderStatus": {"@id": "schema:orderStatus"},
"orderedItem": {"@id": "schema:orderedItem"},
"organizer": {"@id": "schema:organizer"},
"originAddress": {"@id": "schema:originAddress"},
"originatesFrom": {"@id": "schema:originatesFrom"},
"overdosage": {"@id": "schema:overdosage"},
"ownedFrom": {"@id": "schema:ownedFrom", "@type": "DateTime"},
"ownedThrough": {"@id": "schema:ownedThrough", "@type": "DateTime"},
"ownershipFundingInfo": {"@id": "schema:ownershipFundingInfo"},
"owns": {"@id": "schema:owns"},
"pageEnd": {"@id": "schema:pageEnd"},
"pageStart": {"@id": "schema:pageStart"},
"pagination": {"@id": "schema:pagination"},
"parent": {"@id": "schema:parent"},
"parentItem": {"@id": "schema:parentItem"},
"parentOrganization": {"@id": "schema:parentOrganization"},
"parentService": {"@id": "schema:parentService"},
"parents": {"@id": "schema:parents"},
"partOfEpisode": {"@id": "schema:partOfEpisode"},
"partOfInvoice": {"@id": "schema:partOfInvoice"},
"partOfOrder": {"@id": "schema:partOfOrder"},
"partOfSeason": {"@id": "schema:partOfSeason"},
"partOfSeries": {"@id": "schema:partOfSeries"},
"partOfSystem": {"@id": "schema:partOfSystem"},
"partOfTVSeries": {"@id": "schema:partOfTVSeries"},
"partOfTrip": {"@id": "schema:partOfTrip"},
"participant": {"@id": "schema:participant"},
"partySize": {"@id": "schema:partySize"},
"passengerPriorityStatus": {"@id": "schema:passengerPriorityStatus"},
"passengerSequenceNumber": {"@id": "schema:passengerSequenceNumber"},
"pathophysiology": {"@id": "schema:pathophysiology"},
"payload": {"@id": "schema:payload"},
"paymentAccepted": {"@id": "schema:paymentAccepted"},
"paymentDue": {"@id": "schema:paymentDue", "@type": "DateTime"},
"paymentDueDate": {"@id": "schema:paymentDueDate", "@type": "Date"},
"paymentMethod": {"@id": "schema:paymentMethod"},
"paymentMethodId": {"@id": "schema:paymentMethodId"},
"paymentStatus": {"@id": "schema:paymentStatus"},
"paymentUrl": {"@id": "schema:paymentUrl", "@type": "@id"},
"penciler": {"@id": "schema:penciler"},
"percentile10": {"@id": "schema:percentile10"},
"percentile25": {"@id": "schema:percentile25"},
"percentile75": {"@id": "schema:percentile75"},
"percentile90": {"@id": "schema:percentile90"},
"performTime": {"@id": "schema:performTime"},
"performer": {"@id": "schema:performer"},
"performerIn": {"@id": "schema:performerIn"},
"performers": {"@id": "schema:performers"},
"permissionType": {"@id": "schema:permissionType"},
"permissions": {"@id": "schema:permissions"},
"permitAudience": {"@id": "schema:permitAudience"},
"permittedUsage": {"@id": "schema:permittedUsage"},
"petsAllowed": {"@id": "schema:petsAllowed"},
"phoneticText": {"@id": "schema:phoneticText"},
"photo": {"@id": "schema:photo"},
"photos": {"@id": "schema:photos"},
"physicalRequirement": {"@id": "schema:physicalRequirement"},
"physiologicalBenefits": {"@id": "schema:physiologicalBenefits"},
"pickupLocation": {"@id": "schema:pickupLocation"},
"pickupTime": {"@id": "schema:pickupTime", "@type": "DateTime"},
"playMode": {"@id": "schema:playMode"},
"playerType": {"@id": "schema:playerType"},
"playersOnline": {"@id": "schema:playersOnline"},
"polygon": {"@id": "schema:polygon"},
"populationType": {"@id": "schema:populationType"},
"position": {"@id": "schema:position"},
"possibleComplication": {"@id": "schema:possibleComplication"},
"possibleTreatment": {"@id": "schema:possibleTreatment"},
"postOfficeBoxNumber": {"@id": "schema:postOfficeBoxNumber"},
"postOp": {"@id": "schema:postOp"},
"postalCode": {"@id": "schema:postalCode"},
"potentialAction": {"@id": "schema:potentialAction"},
"preOp": {"@id": "schema:preOp"},
"predecessorOf": {"@id": "schema:predecessorOf"},
"pregnancyCategory": {"@id": "schema:pregnancyCategory"},
"pregnancyWarning": {"@id": "schema:pregnancyWarning"},
"prepTime": {"@id": "schema:prepTime"},
"preparation": {"@id": "schema:preparation"},
"prescribingInfo": {"@id": "schema:prescribingInfo", "@type": "@id"},
"prescriptionStatus": {"@id": "schema:prescriptionStatus"},
"previousItem": {"@id": "schema:previousItem"},
"previousStartDate": {"@id": "schema:previousStartDate", "@type": "Date"},
"price": {"@id": "schema:price"},
"priceComponent": {"@id": "schema:priceComponent"},
"priceCurrency": {"@id": "schema:priceCurrency"},
"priceRange": {"@id": "schema:priceRange"},
"priceSpecification": {"@id": "schema:priceSpecification"},
"priceType": {"@id": "schema:priceType"},
"priceValidUntil": {"@id": "schema:priceValidUntil", "@type": "Date"},
"primaryImageOfPage": {"@id": "schema:primaryImageOfPage"},
"primaryPrevention": {"@id": "schema:primaryPrevention"},
"printColumn": {"@id": "schema:printColumn"},
"printEdition": {"@id": "schema:printEdition"},
"printPage": {"@id": "schema:printPage"},
"printSection": {"@id": "schema:printSection"},
"procedure": {"@id": "schema:procedure"},
"procedureType": {"@id": "schema:procedureType"},
"processingTime": {"@id": "schema:processingTime"},
"processorRequirements": {"@id": "schema:processorRequirements"},
"producer": {"@id": "schema:producer"},
"produces": {"@id": "schema:produces"},
"productID": {"@id": "schema:productID"},
"productReturnDays": {"@id": "schema:productReturnDays"},
"productReturnLink": {"@id": "schema:productReturnLink", "@type": "@id"},
"productSupported": {"@id": "schema:productSupported"},
"productionCompany": {"@id": "schema:productionCompany"},
"productionDate": {"@id": "schema:productionDate", "@type": "Date"},
"proficiencyLevel": {"@id": "schema:proficiencyLevel"},
"programMembershipUsed": {"@id": "schema:programMembershipUsed"},
"programName": {"@id": "schema:programName"},
"programPrerequisites": {"@id": "schema:programPrerequisites"},
"programType": {"@id": "schema:programType"},
"programmingLanguage": {"@id": "schema:programmingLanguage"},
"programmingModel": {"@id": "schema:programmingModel"},
"propertyID": {"@id": "schema:propertyID"},
"proprietaryName": {"@id": "schema:proprietaryName"},
"proteinContent": {"@id": "schema:proteinContent"},
"provider": {"@id": "schema:provider"},
"providerMobility": {"@id": "schema:providerMobility"},
"providesBroadcastService": {"@id": "schema:providesBroadcastService"},
"providesService": {"@id": "schema:providesService"},
"publicAccess": {"@id": "schema:publicAccess"},
"publicTransportClosuresInfo": {
"@id": "schema:publicTransportClosuresInfo",
"@type": "@id",
},
"publication": {"@id": "schema:publication"},
"publicationType": {"@id": "schema:publicationType"},
"publishedBy": {"@id": "schema:publishedBy"},
"publishedOn": {"@id": "schema:publishedOn"},
"publisher": {"@id": "schema:publisher"},
"publisherImprint": {"@id": "schema:publisherImprint"},
"publishingPrinciples": {"@id": "schema:publishingPrinciples", "@type": "@id"},
"purchaseDate": {"@id": "schema:purchaseDate", "@type": "Date"},
"qualifications": {"@id": "schema:qualifications"},
"quarantineGuidelines": {"@id": "schema:quarantineGuidelines", "@type": "@id"},
"query": {"@id": "schema:query"},
"quest": {"@id": "schema:quest"},
"question": {"@id": "schema:question"},
"rangeIncludes": {"@id": "schema:rangeIncludes"},
"ratingCount": {"@id": "schema:ratingCount"},
"ratingExplanation": {"@id": "schema:ratingExplanation"},
"ratingValue": {"@id": "schema:ratingValue"},
"readBy": {"@id": "schema:readBy"},
"readonlyValue": {"@id": "schema:readonlyValue"},
"realEstateAgent": {"@id": "schema:realEstateAgent"},
"recipe": {"@id": "schema:recipe"},
"recipeCategory": {"@id": "schema:recipeCategory"},
"recipeCuisine": {"@id": "schema:recipeCuisine"},
"recipeIngredient": {"@id": "schema:recipeIngredient"},
"recipeInstructions": {"@id": "schema:recipeInstructions"},
"recipeYield": {"@id": "schema:recipeYield"},
"recipient": {"@id": "schema:recipient"},
"recognizedBy": {"@id": "schema:recognizedBy"},
"recognizingAuthority": {"@id": "schema:recognizingAuthority"},
"recommendationStrength": {"@id": "schema:recommendationStrength"},
"recommendedIntake": {"@id": "schema:recommendedIntake"},
"recordLabel": {"@id": "schema:recordLabel"},
"recordedAs": {"@id": "schema:recordedAs"},
"recordedAt": {"@id": "schema:recordedAt"},
"recordedIn": {"@id": "schema:recordedIn"},
"recordingOf": {"@id": "schema:recordingOf"},
"recourseLoan": {"@id": "schema:recourseLoan"},
"referenceQuantity": {"@id": "schema:referenceQuantity"},
"referencesOrder": {"@id": "schema:referencesOrder"},
"refundType": {"@id": "schema:refundType"},
"regionDrained": {"@id": "schema:regionDrained"},
"regionsAllowed": {"@id": "schema:regionsAllowed"},
"relatedAnatomy": {"@id": "schema:relatedAnatomy"},
"relatedCondition": {"@id": "schema:relatedCondition"},
"relatedDrug": {"@id": "schema:relatedDrug"},
"relatedLink": {"@id": "schema:relatedLink", "@type": "@id"},
"relatedStructure": {"@id": "schema:relatedStructure"},
"relatedTherapy": {"@id": "schema:relatedTherapy"},
"relatedTo": {"@id": "schema:relatedTo"},
"releaseDate": {"@id": "schema:releaseDate", "@type": "Date"},
"releaseNotes": {"@id": "schema:releaseNotes"},
"releaseOf": {"@id": "schema:releaseOf"},
"releasedEvent": {"@id": "schema:releasedEvent"},
"relevantOccupation": {"@id": "schema:relevantOccupation"},
"relevantSpecialty": {"@id": "schema:relevantSpecialty"},
"remainingAttendeeCapacity": {"@id": "schema:remainingAttendeeCapacity"},
"renegotiableLoan": {"@id": "schema:renegotiableLoan"},
"repeatCount": {"@id": "schema:repeatCount"},
"repeatFrequency": {"@id": "schema:repeatFrequency"},
"repetitions": {"@id": "schema:repetitions"},
"replacee": {"@id": "schema:replacee"},
"replacer": {"@id": "schema:replacer"},
"replyToUrl": {"@id": "schema:replyToUrl", "@type": "@id"},
"reportNumber": {"@id": "schema:reportNumber"},
"representativeOfPage": {"@id": "schema:representativeOfPage"},
"requiredCollateral": {"@id": "schema:requiredCollateral"},
"requiredGender": {"@id": "schema:requiredGender"},
"requiredMaxAge": {"@id": "schema:requiredMaxAge"},
"requiredMinAge": {"@id": "schema:requiredMinAge"},
"requiredQuantity": {"@id": "schema:requiredQuantity"},
"requirements": {"@id": "schema:requirements"},
"requiresSubscription": {"@id": "schema:requiresSubscription"},
"reservationFor": {"@id": "schema:reservationFor"},
"reservationId": {"@id": "schema:reservationId"},
"reservationStatus": {"@id": "schema:reservationStatus"},
"reservedTicket": {"@id": "schema:reservedTicket"},
"responsibilities": {"@id": "schema:responsibilities"},
"restPeriods": {"@id": "schema:restPeriods"},
"result": {"@id": "schema:result"},
"resultComment": {"@id": "schema:resultComment"},
"resultReview": {"@id": "schema:resultReview"},
"returnFees": {"@id": "schema:returnFees"},
"returnPolicyCategory": {"@id": "schema:returnPolicyCategory"},
"review": {"@id": "schema:review"},
"reviewAspect": {"@id": "schema:reviewAspect"},
"reviewBody": {"@id": "schema:reviewBody"},
"reviewCount": {"@id": "schema:reviewCount"},
"reviewRating": {"@id": "schema:reviewRating"},
"reviewedBy": {"@id": "schema:reviewedBy"},
"reviews": {"@id": "schema:reviews"},
"riskFactor": {"@id": "schema:riskFactor"},
"risks": {"@id": "schema:risks"},
"roleName": {"@id": "schema:roleName"},
"roofLoad": {"@id": "schema:roofLoad"},
"rsvpResponse": {"@id": "schema:rsvpResponse"},
"runsTo": {"@id": "schema:runsTo"},
"runtime": {"@id": "schema:runtime"},
"runtimePlatform": {"@id": "schema:runtimePlatform"},
"rxcui": {"@id": "schema:rxcui"},
"safetyConsideration": {"@id": "schema:safetyConsideration"},
"salaryCurrency": {"@id": "schema:salaryCurrency"},
"salaryUponCompletion": {"@id": "schema:salaryUponCompletion"},
"sameAs": {"@id": "schema:sameAs", "@type": "@id"},
"sampleType": {"@id": "schema:sampleType"},
"saturatedFatContent": {"@id": "schema:saturatedFatContent"},
"scheduleTimezone": {"@id": "schema:scheduleTimezone"},
"scheduledPaymentDate": {"@id": "schema:scheduledPaymentDate", "@type": "Date"},
"scheduledTime": {"@id": "schema:scheduledTime", "@type": "DateTime"},
"schemaVersion": {"@id": "schema:schemaVersion"},
"schoolClosuresInfo": {"@id": "schema:schoolClosuresInfo", "@type": "@id"},
"screenCount": {"@id": "schema:screenCount"},
"screenshot": {"@id": "schema:screenshot", "@type": "@id"},
"sdDatePublished": {"@id": "schema:sdDatePublished", "@type": "Date"},
"sdLicense": {"@id": "schema:sdLicense", "@type": "@id"},
"sdPublisher": {"@id": "schema:sdPublisher"},
"season": {"@id": "schema:season", "@type": "@id"},
"seasonNumber": {"@id": "schema:seasonNumber"},
"seasons": {"@id": "schema:seasons"},
"seatNumber": {"@id": "schema:seatNumber"},
"seatRow": {"@id": "schema:seatRow"},
"seatSection": {"@id": "schema:seatSection"},
"seatingCapacity": {"@id": "schema:seatingCapacity"},
"seatingType": {"@id": "schema:seatingType"},
"secondaryPrevention": {"@id": "schema:secondaryPrevention"},
"securityClearanceRequirement": {"@id": "schema:securityClearanceRequirement"},
"securityScreening": {"@id": "schema:securityScreening"},
"seeks": {"@id": "schema:seeks"},
"seller": {"@id": "schema:seller"},
"sender": {"@id": "schema:sender"},
"sensoryRequirement": {"@id": "schema:sensoryRequirement"},
"sensoryUnit": {"@id": "schema:sensoryUnit"},
"serialNumber": {"@id": "schema:serialNumber"},
"seriousAdverseOutcome": {"@id": "schema:seriousAdverseOutcome"},
"serverStatus": {"@id": "schema:serverStatus"},
"servesCuisine": {"@id": "schema:servesCuisine"},
"serviceArea": {"@id": "schema:serviceArea"},
"serviceAudience": {"@id": "schema:serviceAudience"},
"serviceLocation": {"@id": "schema:serviceLocation"},
"serviceOperator": {"@id": "schema:serviceOperator"},
"serviceOutput": {"@id": "schema:serviceOutput"},
"servicePhone": {"@id": "schema:servicePhone"},
"servicePostalAddress": {"@id": "schema:servicePostalAddress"},
"serviceSmsNumber": {"@id": "schema:serviceSmsNumber"},
"serviceType": {"@id": "schema:serviceType"},
"serviceUrl": {"@id": "schema:serviceUrl", "@type": "@id"},
"servingSize": {"@id": "schema:servingSize"},
"sharedContent": {"@id": "schema:sharedContent"},
"sibling": {"@id": "schema:sibling"},
"siblings": {"@id": "schema:siblings"},
"signDetected": {"@id": "schema:signDetected"},
"signOrSymptom": {"@id": "schema:signOrSymptom"},
"significance": {"@id": "schema:significance"},
"significantLink": {"@id": "schema:significantLink", "@type": "@id"},
"significantLinks": {"@id": "schema:significantLinks", "@type": "@id"},
"skills": {"@id": "schema:skills"},
"sku": {"@id": "schema:sku"},
"slogan": {"@id": "schema:slogan"},
"smokingAllowed": {"@id": "schema:smokingAllowed"},
"sodiumContent": {"@id": "schema:sodiumContent"},
"softwareAddOn": {"@id": "schema:softwareAddOn"},
"softwareHelp": {"@id": "schema:softwareHelp"},
"softwareRequirements": {"@id": "schema:softwareRequirements"},
"softwareVersion": {"@id": "schema:softwareVersion"},
"sourceOrganization": {"@id": "schema:sourceOrganization"},
"sourcedFrom": {"@id": "schema:sourcedFrom"},
"spatial": {"@id": "schema:spatial"},
"spatialCoverage": {"@id": "schema:spatialCoverage"},
"speakable": {"@id": "schema:speakable", "@type": "@id"},
"specialCommitments": {"@id": "schema:specialCommitments"},
"specialOpeningHoursSpecification": {
"@id": "schema:specialOpeningHoursSpecification"
},
"specialty": {"@id": "schema:specialty"},
"speechToTextMarkup": {"@id": "schema:speechToTextMarkup"},
"speed": {"@id": "schema:speed"},
"spokenByCharacter": {"@id": "schema:spokenByCharacter"},
"sponsor": {"@id": "schema:sponsor"},
"sport": {"@id": "schema:sport"},
"sportsActivityLocation": {"@id": "schema:sportsActivityLocation"},
"sportsEvent": {"@id": "schema:sportsEvent"},
"sportsTeam": {"@id": "schema:sportsTeam"},
"spouse": {"@id": "schema:spouse"},
"stage": {"@id": "schema:stage"},
"stageAsNumber": {"@id": "schema:stageAsNumber"},
"starRating": {"@id": "schema:starRating"},
"startDate": {"@id": "schema:startDate", "@type": "Date"},
"startOffset": {"@id": "schema:startOffset"},
"startTime": {"@id": "schema:startTime", "@type": "DateTime"},
"status": {"@id": "schema:status"},
"steeringPosition": {"@id": "schema:steeringPosition"},
"step": {"@id": "schema:step"},
"stepValue": {"@id": "schema:stepValue"},
"steps": {"@id": "schema:steps"},
"storageRequirements": {"@id": "schema:storageRequirements"},
"streetAddress": {"@id": "schema:streetAddress"},
"strengthUnit": {"@id": "schema:strengthUnit"},
"strengthValue": {"@id": "schema:strengthValue"},
"structuralClass": {"@id": "schema:structuralClass"},
"study": {"@id": "schema:study"},
"studyDesign": {"@id": "schema:studyDesign"},
"studyLocation": {"@id": "schema:studyLocation"},
"studySubject": {"@id": "schema:studySubject"},
"stupidProperty": {"@id": "schema:stupidProperty"},
"subEvent": {"@id": "schema:subEvent"},
"subEvents": {"@id": "schema:subEvents"},
"subOrganization": {"@id": "schema:subOrganization"},
"subReservation": {"@id": "schema:subReservation"},
"subStageSuffix": {"@id": "schema:subStageSuffix"},
"subStructure": {"@id": "schema:subStructure"},
"subTest": {"@id": "schema:subTest"},
"subTrip": {"@id": "schema:subTrip"},
"subjectOf": {"@id": "schema:subjectOf"},
"subtitleLanguage": {"@id": "schema:subtitleLanguage"},
"successorOf": {"@id": "schema:successorOf"},
"sugarContent": {"@id": "schema:sugarContent"},
"suggestedAnswer": {"@id": "schema:suggestedAnswer"},
"suggestedGender": {"@id": "schema:suggestedGender"},
"suggestedMaxAge": {"@id": "schema:suggestedMaxAge"},
"suggestedMinAge": {"@id": "schema:suggestedMinAge"},
"suitableForDiet": {"@id": "schema:suitableForDiet"},
"superEvent": {"@id": "schema:superEvent"},
"supersededBy": {"@id": "schema:supersededBy"},
"supply": {"@id": "schema:supply"},
"supplyTo": {"@id": "schema:supplyTo"},
"supportingData": {"@id": "schema:supportingData"},
"surface": {"@id": "schema:surface"},
"target": {"@id": "schema:target"},
"targetCollection": {"@id": "schema:targetCollection"},
"targetDescription": {"@id": "schema:targetDescription"},
"targetName": {"@id": "schema:targetName"},
"targetPlatform": {"@id": "schema:targetPlatform"},
"targetPopulation": {"@id": "schema:targetPopulation"},
"targetProduct": {"@id": "schema:targetProduct"},
"targetUrl": {"@id": "schema:targetUrl", "@type": "@id"},
"taxID": {"@id": "schema:taxID"},
"telephone": {"@id": "schema:telephone"},
"temporal": {"@id": "schema:temporal"},
"temporalCoverage": {"@id": "schema:temporalCoverage"},
"termCode": {"@id": "schema:termCode"},
"termDuration": {"@id": "schema:termDuration"},
"termsOfService": {"@id": "schema:termsOfService"},
"termsPerYear": {"@id": "schema:termsPerYear"},
"text": {"@id": "schema:text"},
"textValue": {"@id": "schema:textValue"},
"thumbnail": {"@id": "schema:thumbnail"},
"thumbnailUrl": {"@id": "schema:thumbnailUrl", "@type": "@id"},
"tickerSymbol": {"@id": "schema:tickerSymbol"},
"ticketNumber": {"@id": "schema:ticketNumber"},
"ticketToken": {"@id": "schema:ticketToken"},
"ticketedSeat": {"@id": "schema:ticketedSeat"},
"timeOfDay": {"@id": "schema:timeOfDay"},
"timeRequired": {"@id": "schema:timeRequired"},
"timeToComplete": {"@id": "schema:timeToComplete"},
"tissueSample": {"@id": "schema:tissueSample"},
"title": {"@id": "schema:title"},
"toLocation": {"@id": "schema:toLocation"},
"toRecipient": {"@id": "schema:toRecipient"},
"tongueWeight": {"@id": "schema:tongueWeight"},
"tool": {"@id": "schema:tool"},
"torque": {"@id": "schema:torque"},
"totalJobOpenings": {"@id": "schema:totalJobOpenings"},
"totalPaymentDue": {"@id": "schema:totalPaymentDue"},
"totalPrice": {"@id": "schema:totalPrice"},
"totalTime": {"@id": "schema:totalTime"},
"tourBookingPage": {"@id": "schema:tourBookingPage", "@type": "@id"},
"touristType": {"@id": "schema:touristType"},
"track": {"@id": "schema:track"},
"trackingNumber": {"@id": "schema:trackingNumber"},
"trackingUrl": {"@id": "schema:trackingUrl", "@type": "@id"},
"tracks": {"@id": "schema:tracks"},
"trailer": {"@id": "schema:trailer"},
"trailerWeight": {"@id": "schema:trailerWeight"},
"trainName": {"@id": "schema:trainName"},
"trainNumber": {"@id": "schema:trainNumber"},
"trainingSalary": {"@id": "schema:trainingSalary"},
"transFatContent": {"@id": "schema:transFatContent"},
"transcript": {"@id": "schema:transcript"},
"translationOfWork": {"@id": "schema:translationOfWork"},
"translator": {"@id": "schema:translator"},
"transmissionMethod": {"@id": "schema:transmissionMethod"},
"travelBans": {"@id": "schema:travelBans", "@type": "@id"},
"trialDesign": {"@id": "schema:trialDesign"},
"tributary": {"@id": "schema:tributary"},
"typeOfBed": {"@id": "schema:typeOfBed"},
"typeOfGood": {"@id": "schema:typeOfGood"},
"typicalAgeRange": {"@id": "schema:typicalAgeRange"},
"typicalCreditsPerTerm": {"@id": "schema:typicalCreditsPerTerm"},
"typicalTest": {"@id": "schema:typicalTest"},
"underName": {"@id": "schema:underName"},
"unitCode": {"@id": "schema:unitCode"},
"unitText": {"@id": "schema:unitText"},
"unnamedSourcesPolicy": {"@id": "schema:unnamedSourcesPolicy", "@type": "@id"},
"unsaturatedFatContent": {"@id": "schema:unsaturatedFatContent"},
"uploadDate": {"@id": "schema:uploadDate", "@type": "Date"},
"upvoteCount": {"@id": "schema:upvoteCount"},
"url": {"@id": "schema:url", "@type": "@id"},
"urlTemplate": {"@id": "schema:urlTemplate"},
"usageInfo": {"@id": "schema:usageInfo", "@type": "@id"},
"usedToDiagnose": {"@id": "schema:usedToDiagnose"},
"userInteractionCount": {"@id": "schema:userInteractionCount"},
"usesDevice": {"@id": "schema:usesDevice"},
"usesHealthPlanIdStandard": {"@id": "schema:usesHealthPlanIdStandard"},
"validFor": {"@id": "schema:validFor"},
"validFrom": {"@id": "schema:validFrom", "@type": "Date"},
"validIn": {"@id": "schema:validIn"},
"validThrough": {"@id": "schema:validThrough", "@type": "Date"},
"validUntil": {"@id": "schema:validUntil", "@type": "Date"},
"value": {"@id": "schema:value"},
"valueAddedTaxIncluded": {"@id": "schema:valueAddedTaxIncluded"},
"valueMaxLength": {"@id": "schema:valueMaxLength"},
"valueMinLength": {"@id": "schema:valueMinLength"},
"valueName": {"@id": "schema:valueName"},
"valuePattern": {"@id": "schema:valuePattern"},
"valueReference": {"@id": "schema:valueReference"},
"valueRequired": {"@id": "schema:valueRequired"},
"variableMeasured": {"@id": "schema:variableMeasured"},
"variablesMeasured": {"@id": "schema:variablesMeasured"},
"variantCover": {"@id": "schema:variantCover"},
"vatID": {"@id": "schema:vatID"},
"vehicleConfiguration": {"@id": "schema:vehicleConfiguration"},
"vehicleEngine": {"@id": "schema:vehicleEngine"},
"vehicleIdentificationNumber": {"@id": "schema:vehicleIdentificationNumber"},
"vehicleInteriorColor": {"@id": "schema:vehicleInteriorColor"},
"vehicleInteriorType": {"@id": "schema:vehicleInteriorType"},
"vehicleModelDate": {"@id": "schema:vehicleModelDate", "@type": "Date"},
"vehicleSeatingCapacity": {"@id": "schema:vehicleSeatingCapacity"},
"vehicleSpecialUsage": {"@id": "schema:vehicleSpecialUsage"},
"vehicleTransmission": {"@id": "schema:vehicleTransmission"},
"vendor": {"@id": "schema:vendor"},
"verificationFactCheckingPolicy": {
"@id": "schema:verificationFactCheckingPolicy",
"@type": "@id",
},
"version": {"@id": "schema:version"},
"video": {"@id": "schema:video"},
"videoFormat": {"@id": "schema:videoFormat"},
"videoFrameSize": {"@id": "schema:videoFrameSize"},
"videoQuality": {"@id": "schema:videoQuality"},
"volumeNumber": {"@id": "schema:volumeNumber"},
"warning": {"@id": "schema:warning"},
"warranty": {"@id": "schema:warranty"},
"warrantyPromise": {"@id": "schema:warrantyPromise"},
"warrantyScope": {"@id": "schema:warrantyScope"},
"webCheckinTime": {"@id": "schema:webCheckinTime", "@type": "DateTime"},
"webFeed": {"@id": "schema:webFeed", "@type": "@id"},
"weight": {"@id": "schema:weight"},
"weightTotal": {"@id": "schema:weightTotal"},
"wheelbase": {"@id": "schema:wheelbase"},
"width": {"@id": "schema:width"},
"winner": {"@id": "schema:winner"},
"wordCount": {"@id": "schema:wordCount"},
"workExample": {"@id": "schema:workExample"},
"workFeatured": {"@id": "schema:workFeatured"},
"workHours": {"@id": "schema:workHours"},
"workLocation": {"@id": "schema:workLocation"},
"workPerformed": {"@id": "schema:workPerformed"},
"workPresented": {"@id": "schema:workPresented"},
"workTranslation": {"@id": "schema:workTranslation"},
"workload": {"@id": "schema:workload"},
"worksFor": {"@id": "schema:worksFor"},
"worstRating": {"@id": "schema:worstRating"},
"xpath": {"@id": "schema:xpath"},
"yearBuilt": {"@id": "schema:yearBuilt"},
"yearlyRevenue": {"@id": "schema:yearlyRevenue"},
"yearsInOperation": {"@id": "schema:yearsInOperation"},
"yield": {"@id": "schema:yield"},
"http://publications.europa.eu/mdr/eli/index.html": {
"@id": "http://publications.europa.eu/mdr/eli/index.html"
},
"httpMethod": {"@id": "schema:httpMethod"},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#Automotive_Ontology_Working_Group": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#Automotive_Ontology_Working_Group"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#FIBO": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#FIBO"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#GLEIF": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#GLEIF"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#IIT-CNR.it": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#IIT-CNR.it"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#MBZ": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#MBZ"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#Tourism": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#Tourism"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_ActionCollabClass": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_ActionCollabClass"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_DatasetClass": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_DatasetClass"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_GoodRelationsClass": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_GoodRelationsClass"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_GoodRelationsTerms": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_GoodRelationsTerms"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_LRMIClass": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_LRMIClass"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_QAStackExchange": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_QAStackExchange"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_WikiDoc": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_WikiDoc"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_bibex": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_bibex"
},
"http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_rNews": {
"@id": "http://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#source_rNews"
},
"https://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#STI_Accommodation_Ontology": {
"@id": "https://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#STI_Accommodation_Ontology"
},
"https://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#TP": {
"@id": "https://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#TP"
},
"https://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#TP-draws": {
"@id": "https://www.w3.org/wiki/WebSchemas/SchemaDotOrgSources#TP-draws"
},
}
CONTEXT["sc"] = "http://schema.org#"
import logging import logging
import mimetypes import os
import re
import urllib.parse import urllib.parse
import uuid
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db import transaction
from django.db.models import Q
from django.urls import reverse
from django.utils import timezone
from rest_framework import serializers from rest_framework import serializers
from funkwhale_api.common import utils as funkwhale_utils from funkwhale_api.common import fields
from funkwhale_api.common import models as common_models
from funkwhale_api.common import utils as common_utils
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.federation import activity, actors, contexts, jsonld, models, utils
from funkwhale_api.history import models as history_models
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.moderation import serializers as moderation_serializers
from funkwhale_api.moderation import signals as moderation_signals
from funkwhale_api.music import licenses
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.music import tasks as music_tasks
from funkwhale_api.playlists import models as playlists_models
from funkwhale_api.tags import models as tags_models
from . import activity, contexts, jsonld, models, utils logger = logging.getLogger(__name__)
AP_CONTEXT = jsonld.get_default_context()
logger = logging.getLogger(__name__) def include_if_not_none(data, value, field):
if value is not None:
data[field] = value
class LinkSerializer(jsonld.JsonLdSerializer): class MultipleSerializer(serializers.Serializer):
type = serializers.ChoiceField(choices=[contexts.AS.Link]) """
href = serializers.URLField(max_length=500) A serializer that will try multiple serializers in turn
mediaType = serializers.CharField() """
def __init__(self, *args, **kwargs):
self.allowed = kwargs.pop("allowed")
super().__init__(*args, **kwargs)
def to_internal_value(self, v):
last_exception = None
for serializer_class in self.allowed:
s = serializer_class(data=v)
try:
s.is_valid(raise_exception=True)
except serializers.ValidationError as e:
last_exception = e
else:
return s.validated_data
raise last_exception
class TruncatedCharField(serializers.CharField):
def __init__(self, *args, **kwargs):
self.truncate_length = kwargs.pop("truncate_length")
super().__init__(*args, **kwargs)
def to_internal_value(self, v):
v = super().to_internal_value(v)
if v:
v = v[: self.truncate_length]
return v
class TagSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Hashtag])
name = serializers.CharField(max_length=100)
class Meta: class Meta:
jsonld_mapping = { jsonld_mapping = {"name": jsonld.first_val(contexts.AS.name)}
"href": jsonld.first_id(contexts.AS.href),
"mediaType": jsonld.first_val(contexts.AS.mediaType), def validate_name(self, value):
} if value.startswith("#"):
# remove trailing #
value = value[1:]
return value
def tag_list(tagged_items):
return [
repr_tag(item.tag.name)
for item in sorted(set(tagged_items.all()), key=lambda i: i.tag.name)
]
def is_mimetype(mt, allowed_mimetypes):
for allowed in allowed_mimetypes:
if allowed.endswith("/*"):
if mt.startswith(allowed.replace("*", "")):
return True
else:
if mt == allowed:
return True
return False
class MediaSerializer(jsonld.JsonLdSerializer):
mediaType = serializers.CharField()
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.allowed_mimetypes = kwargs.pop("allowed_mimetypes", []) self.allowed_mimetypes = kwargs.pop("allowed_mimetypes", [])
self.allow_empty_mimetype = kwargs.pop("allow_empty_mimetype", False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["mediaType"].required = not self.allow_empty_mimetype
self.fields["mediaType"].allow_null = self.allow_empty_mimetype
def validate_mediaType(self, v): def validate_mediaType(self, v):
if not self.allowed_mimetypes: if not self.allowed_mimetypes:
# no restrictions # no restrictions
return v return v
for mt in self.allowed_mimetypes: if self.allow_empty_mimetype and not v:
if mt.endswith("/*"): return None
if v.startswith(mt.replace("*", "")):
return v if not is_mimetype(v, self.allowed_mimetypes):
else: raise serializers.ValidationError(
if v == mt: f"Invalid mimetype {v}. Allowed: {self.allowed_mimetypes}"
)
return v return v
class LinkSerializer(MediaSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Link])
href = serializers.URLField(max_length=500)
bitrate = serializers.IntegerField(min_value=0, required=False)
size = serializers.IntegerField(min_value=0, required=False)
class Meta:
jsonld_mapping = {
"href": jsonld.first_id(contexts.AS.href),
"mediaType": jsonld.first_val(contexts.AS.mediaType),
"bitrate": jsonld.first_val(contexts.FW.bitrate),
"size": jsonld.first_val(contexts.FW.size),
}
class LinkListSerializer(serializers.ListField):
def __init__(self, *args, **kwargs):
kwargs.setdefault("child", LinkSerializer(jsonld_expand=False))
self.keep_mediatype = kwargs.pop("keep_mediatype", [])
super().__init__(*args, **kwargs)
def to_internal_value(self, v):
links = super().to_internal_value(v)
if not self.keep_mediatype:
# no further filtering required
return links
links = [
link
for link in links
if link.get("mediaType")
and is_mimetype(link["mediaType"], self.keep_mediatype)
]
if not self.allow_empty and len(links) == 0:
self.fail("empty")
return links
class ImageSerializer(MediaSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Image, contexts.AS.Link])
href = serializers.URLField(max_length=500, required=False)
url = serializers.URLField(max_length=500, required=False)
class Meta:
jsonld_mapping = {
"url": jsonld.first_id(contexts.AS.url),
"href": jsonld.first_id(contexts.AS.href),
"mediaType": jsonld.first_val(contexts.AS.mediaType),
}
def validate(self, data):
validated_data = super().validate(data)
if "url" not in validated_data:
try:
validated_data["url"] = validated_data["href"]
except KeyError:
if self.required:
raise serializers.ValidationError( raise serializers.ValidationError(
"Invalid mimetype {}. Allowed: {}".format(v, self.allowed_mimetypes) "You need to provide a url or href"
) )
return validated_data
class URLSerializer(jsonld.JsonLdSerializer):
href = serializers.URLField(max_length=500)
mediaType = serializers.CharField(required=False)
class Meta:
jsonld_mapping = {
"href": jsonld.first_id(contexts.AS.href, aliases=[jsonld.raw("@id")]),
"mediaType": jsonld.first_val(contexts.AS.mediaType),
}
class EndpointsSerializer(jsonld.JsonLdSerializer): class EndpointsSerializer(jsonld.JsonLdSerializer):
sharedInbox = serializers.URLField(max_length=500, required=False) sharedInbox = serializers.URLField(max_length=500, required=False)
...@@ -61,23 +215,80 @@ class PublicKeySerializer(jsonld.JsonLdSerializer): ...@@ -61,23 +215,80 @@ class PublicKeySerializer(jsonld.JsonLdSerializer):
jsonld_mapping = {"publicKeyPem": jsonld.first_val(contexts.SEC.publicKeyPem)} jsonld_mapping = {"publicKeyPem": jsonld.first_val(contexts.SEC.publicKeyPem)}
def get_by_media_type(urls, media_type):
for url in urls:
if url.get("mediaType", "text/html") == media_type:
return url
class BasicActorSerializer(jsonld.JsonLdSerializer):
id = serializers.URLField(max_length=500)
type = serializers.ChoiceField(
choices=[getattr(contexts.AS, c[0]) for c in models.TYPE_CHOICES]
)
class Meta:
jsonld_mapping = {}
class ActorSerializer(jsonld.JsonLdSerializer): class ActorSerializer(jsonld.JsonLdSerializer):
id = serializers.URLField(max_length=500) id = serializers.URLField(max_length=500)
outbox = serializers.URLField(max_length=500) outbox = serializers.URLField(max_length=500, required=False)
inbox = serializers.URLField(max_length=500) inbox = serializers.URLField(max_length=500, required=False)
url = serializers.ListField(
child=URLSerializer(jsonld_expand=False), required=False, min_length=0
)
type = serializers.ChoiceField( type = serializers.ChoiceField(
choices=[getattr(contexts.AS, c[0]) for c in models.TYPE_CHOICES] choices=[getattr(contexts.AS, c[0]) for c in models.TYPE_CHOICES]
) )
preferredUsername = serializers.CharField() preferredUsername = serializers.CharField()
manuallyApprovesFollowers = serializers.NullBooleanField(required=False) manuallyApprovesFollowers = serializers.BooleanField(
name = serializers.CharField(required=False, max_length=200) required=False, allow_null=True
summary = serializers.CharField(max_length=None, required=False) )
followers = serializers.URLField(max_length=500) name = serializers.CharField(
required=False, max_length=200, allow_blank=True, allow_null=True
)
summary = TruncatedCharField(
truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH,
required=False,
allow_null=True,
allow_blank=True,
)
followers = serializers.URLField(max_length=500, required=False)
following = serializers.URLField(max_length=500, required=False, allow_null=True) following = serializers.URLField(max_length=500, required=False, allow_null=True)
publicKey = PublicKeySerializer(required=False) publicKey = PublicKeySerializer(required=False)
endpoints = EndpointsSerializer(required=False) endpoints = EndpointsSerializer(required=False)
icon = ImageSerializer(
allowed_mimetypes=["image/*"],
allow_null=True,
required=False,
allow_empty_mimetype=True,
)
attributedTo = serializers.URLField(max_length=500, required=False)
tags = serializers.ListField(min_length=0, required=False, allow_null=True)
audience = serializers.ChoiceField(
fields.PRIVACY_LEVEL_CHOICES, required=False, allow_null=True
)
def validate_tags(self, tags):
valid_tags = []
for tag in tags:
s = TagSerializer(data=tag)
if s.is_valid():
valid_tags.append(s.validated_data)
return valid_tags
category = serializers.CharField(required=False)
# languages = serializers.Char(
# music_models.ARTIST_CONTENT_CATEGORY_CHOICES, required=False, default="music",
# )
class Meta: class Meta:
# not strictly necessary because it's not a model serializer
# but used by tasks.py/fetch
model = models.Actor
jsonld_mapping = { jsonld_mapping = {
"outbox": jsonld.first_id(contexts.AS.outbox), "outbox": jsonld.first_id(contexts.AS.outbox),
"inbox": jsonld.first_id(contexts.LDP.inbox), "inbox": jsonld.first_id(contexts.LDP.inbox),
...@@ -92,8 +303,22 @@ class ActorSerializer(jsonld.JsonLdSerializer): ...@@ -92,8 +303,22 @@ class ActorSerializer(jsonld.JsonLdSerializer):
), ),
"mediaType": jsonld.first_val(contexts.AS.mediaType), "mediaType": jsonld.first_val(contexts.AS.mediaType),
"endpoints": jsonld.first_obj(contexts.AS.endpoints), "endpoints": jsonld.first_obj(contexts.AS.endpoints),
"icon": jsonld.first_obj(contexts.AS.icon),
"url": jsonld.raw(contexts.AS.url),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
"tags": jsonld.raw(contexts.AS.tag),
"category": jsonld.first_val(contexts.SC.category),
"audience": jsonld.first_id(contexts.AS.audience),
# "language": jsonld.first_val(contexts.SC.inLanguage),
} }
def validate_category(self, v):
return (
v
if v in [t for t, _ in music_models.ARTIST_CONTENT_CATEGORY_CHOICES]
else None
)
def to_representation(self, instance): def to_representation(self, instance):
ret = { ret = {
"id": instance.fid, "id": instance.fid,
...@@ -108,49 +333,92 @@ class ActorSerializer(jsonld.JsonLdSerializer): ...@@ -108,49 +333,92 @@ class ActorSerializer(jsonld.JsonLdSerializer):
ret["followers"] = instance.followers_url ret["followers"] = instance.followers_url
if instance.following_url: if instance.following_url:
ret["following"] = instance.following_url ret["following"] = instance.following_url
if instance.summary:
ret["summary"] = instance.summary
if instance.manually_approves_followers is not None: if instance.manually_approves_followers is not None:
ret["manuallyApprovesFollowers"] = instance.manually_approves_followers ret["manuallyApprovesFollowers"] = instance.manually_approves_followers
ret["@context"] = AP_CONTEXT if instance.summary_obj_id:
ret["summary"] = instance.summary_obj.rendered
urls = []
if instance.url:
urls.append(
{"type": "Link", "href": instance.url, "mediaType": "text/html"}
)
if hasattr(instance, "audience"):
ret["audience"] = instance.audience
channel = instance.get_channel()
if channel:
ret["url"] = [
{
"type": "Link",
"href": (
instance.channel.get_absolute_url()
if instance.channel.artist.is_local
else instance.get_absolute_url()
),
"mediaType": "text/html",
},
{
"type": "Link",
"href": instance.channel.get_rss_url(),
"mediaType": "application/rss+xml",
},
]
include_image(ret, channel.artist.attachment_cover, "icon")
if channel.artist.description_id:
ret["summary"] = channel.artist.description.rendered
ret["attributedTo"] = channel.attributed_to.fid
ret["category"] = channel.artist.content_category
ret["tag"] = tag_list(channel.artist.tagged_items.all())
else:
ret["url"] = [
{
"type": "Link",
"href": instance.get_absolute_url(),
"mediaType": "text/html",
}
]
include_image(ret, instance.attachment_icon, "icon")
ret["@context"] = jsonld.get_default_context()
if instance.public_key: if instance.public_key:
ret["publicKey"] = { ret["publicKey"] = {
"owner": instance.fid, "owner": instance.fid,
"publicKeyPem": instance.public_key, "publicKeyPem": instance.public_key,
"id": "{}#main-key".format(instance.fid), "id": f"{instance.fid}#main-key",
} }
ret["endpoints"] = {} ret["endpoints"] = {}
if instance.shared_inbox_url: if instance.shared_inbox_url:
ret["endpoints"]["sharedInbox"] = instance.shared_inbox_url ret["endpoints"]["sharedInbox"] = instance.shared_inbox_url
try:
if instance.user.avatar:
ret["icon"] = {
"type": "Image",
"mediaType": mimetypes.guess_type(instance.user.avatar.path)[0],
"url": utils.full_url(instance.user.avatar.crop["400x400"].url),
}
except ObjectDoesNotExist:
pass
return ret return ret
def prepare_missing_fields(self): def prepare_missing_fields(self):
kwargs = { kwargs = {
"fid": self.validated_data["id"], "fid": self.validated_data["id"],
"outbox_url": self.validated_data["outbox"], "outbox_url": self.validated_data.get("outbox"),
"inbox_url": self.validated_data["inbox"], "inbox_url": self.validated_data.get("inbox"),
"following_url": self.validated_data.get("following"), "following_url": self.validated_data.get("following"),
"followers_url": self.validated_data.get("followers"), "followers_url": self.validated_data.get("followers"),
"summary": self.validated_data.get("summary"),
"type": self.validated_data["type"], "type": self.validated_data["type"],
"name": self.validated_data.get("name"), "name": self.validated_data.get("name"),
"preferred_username": self.validated_data["preferredUsername"], "preferred_username": self.validated_data["preferredUsername"],
} }
url = get_by_media_type(self.validated_data.get("url", []), "text/html")
if url:
kwargs["url"] = url["href"]
maf = self.validated_data.get("manuallyApprovesFollowers") maf = self.validated_data.get("manuallyApprovesFollowers")
if maf is not None: if maf is not None:
kwargs["manually_approves_followers"] = maf kwargs["manually_approves_followers"] = maf
domain = urllib.parse.urlparse(kwargs["fid"]).netloc domain = urllib.parse.urlparse(kwargs["fid"]).netloc
kwargs["domain"] = models.Domain.objects.get_or_create(pk=domain)[0] domain, domain_created = models.Domain.objects.get_or_create(pk=domain)
if domain_created and not domain.is_local:
from . import tasks
# first time we see the domain, we trigger nodeinfo fetching
tasks.update_domain_nodeinfo(domain_name=domain.name)
kwargs["domain"] = domain
for endpoint, url in self.validated_data.get("endpoints", {}).items(): for endpoint, url in self.validated_data.get("endpoints", {}).items():
if endpoint == "sharedInbox": if endpoint == "sharedInbox":
kwargs["shared_inbox_url"] = url kwargs["shared_inbox_url"] = url
...@@ -171,18 +439,112 @@ class ActorSerializer(jsonld.JsonLdSerializer): ...@@ -171,18 +439,112 @@ class ActorSerializer(jsonld.JsonLdSerializer):
def save(self, **kwargs): def save(self, **kwargs):
d = self.prepare_missing_fields() d = self.prepare_missing_fields()
d.update(kwargs) d.update(kwargs)
return models.Actor.objects.update_or_create(fid=d["fid"], defaults=d)[0] actor = models.Actor.objects.update_or_create(fid=d["fid"], defaults=d)[0]
common_utils.attach_content(
actor, "summary_obj", self.validated_data["summary"]
)
if "icon" in self.validated_data:
new_value = self.validated_data["icon"]
common_utils.attach_file(
actor,
"attachment_icon",
(
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value
else None
),
)
rss_url = get_by_media_type(
self.validated_data.get("url", []), "application/rss+xml"
)
if rss_url:
rss_url = rss_url["href"]
attributed_to = self.validated_data.get("attributedTo", actor.fid)
if rss_url:
# if the actor is attributed to another actor, and there is a RSS url,
# then we consider it's a channel
create_or_update_channel(
actor,
rss_url=rss_url,
attributed_to_fid=attributed_to,
**self.validated_data,
)
return actor
def validate_summary(self, value): def validate(self, data):
if value: validated_data = super().validate(data)
return value[:500] if "summary" in data:
validated_data["summary"] = {
"content_type": "text/html",
"text": data["summary"],
}
else:
validated_data["summary"] = None
return validated_data
def create_or_update_channel(actor, rss_url, attributed_to_fid, **validated_data):
from funkwhale_api.audio import models as audio_models
attributed_to = actors.get_actor(attributed_to_fid)
artist_defaults = {
"name": validated_data.get("name", validated_data["preferredUsername"]),
"fid": validated_data["id"],
"content_category": validated_data.get("category", "music") or "music",
"attributed_to": attributed_to,
}
artist, created = music_models.Artist.objects.update_or_create(
channel__attributed_to=attributed_to,
channel__actor=actor,
defaults=artist_defaults,
)
common_utils.attach_content(artist, "description", validated_data.get("summary"))
if "icon" in validated_data:
new_value = validated_data["icon"]
common_utils.attach_file(
artist,
"attachment_cover",
(
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value
else None
),
)
tags = [t["name"] for t in validated_data.get("tags", []) or []]
tags_models.set_tags(artist, *tags)
if created:
uid = uuid.uuid4()
fid = utils.full_url(
reverse("federation:music:libraries-detail", kwargs={"uuid": uid})
)
library = attributed_to.libraries.create(
privacy_level="everyone",
name=artist_defaults["name"],
fid=fid,
uuid=uid,
)
else:
library = artist.channel.library
channel_defaults = {
"actor": actor,
"attributed_to": attributed_to,
"rss_url": rss_url,
"artist": artist,
"library": library,
}
channel, created = audio_models.Channel.objects.update_or_create(
actor=actor,
attributed_to=attributed_to,
defaults=channel_defaults,
)
return channel
class APIActorSerializer(serializers.ModelSerializer): class APIActorSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.Actor model = models.Actor
fields = [ fields = [
"id",
"fid", "fid",
"url", "url",
"creation_date", "creation_date",
...@@ -194,6 +556,7 @@ class APIActorSerializer(serializers.ModelSerializer): ...@@ -194,6 +556,7 @@ class APIActorSerializer(serializers.ModelSerializer):
"type", "type",
"manually_approves_followers", "manually_approves_followers",
"full_username", "full_username",
"is_local",
] ]
...@@ -201,6 +564,7 @@ class BaseActivitySerializer(serializers.Serializer): ...@@ -201,6 +564,7 @@ class BaseActivitySerializer(serializers.Serializer):
id = serializers.URLField(max_length=500, required=False) id = serializers.URLField(max_length=500, required=False)
type = serializers.CharField(max_length=100) type = serializers.CharField(max_length=100)
actor = serializers.URLField(max_length=500) actor = serializers.URLField(max_length=500)
object = serializers.JSONField(required=False, allow_null=True)
def validate_actor(self, v): def validate_actor(self, v):
expected = self.context.get("actor") expected = self.context.get("actor")
...@@ -223,17 +587,30 @@ class BaseActivitySerializer(serializers.Serializer): ...@@ -223,17 +587,30 @@ class BaseActivitySerializer(serializers.Serializer):
) )
def validate(self, data): def validate(self, data):
data["recipients"] = self.validate_recipients(self.initial_data) self.validate_recipients(data, self.initial_data)
return super().validate(data) return super().validate(data)
def validate_recipients(self, payload): def validate_recipients(self, data, payload):
""" """
Ensure we have at least a to/cc field with valid actors Ensure we have at least a to/cc field with valid actors
""" """
to = payload.get("to", []) data["to"] = payload.get("to", [])
cc = payload.get("cc", []) data["cc"] = payload.get("cc", [])
if (
not data["to"]
and data.get("type") in ["Follow", "Accept"]
and data.get("object")
):
# there isn't always a to field for Accept/Follow
# in their follow activity, so we consider the recipient
# to be the follow object
if data["type"] == "Follow":
data["to"].append(str(data.get("object")))
else:
data["to"].append(data.get("object", {}).get("actor"))
if not to and not cc: if not data["to"] and not data["cc"] and not self.context.get("recipients"):
raise serializers.ValidationError( raise serializers.ValidationError(
"We cannot handle an activity with no recipient" "We cannot handle an activity with no recipient"
) )
...@@ -283,22 +660,39 @@ class FollowSerializer(serializers.Serializer): ...@@ -283,22 +660,39 @@ class FollowSerializer(serializers.Serializer):
def save(self, **kwargs): def save(self, **kwargs):
target = self.validated_data["object"] target = self.validated_data["object"]
if target._meta.label == "music.Library": if target._meta.label == "music.Library":
follow_class = models.LibraryFollow follow_class = models.LibraryFollow
else: else:
follow_class = models.Follow follow_class = models.Follow
defaults = kwargs defaults = kwargs
defaults["fid"] = self.validated_data["id"] defaults["fid"] = self.validated_data["id"]
return follow_class.objects.update_or_create( approved = kwargs.pop("approved", None)
follow, created = follow_class.objects.update_or_create(
actor=self.validated_data["actor"], actor=self.validated_data["actor"],
target=self.validated_data["object"], target=self.validated_data["object"],
defaults=defaults, defaults=defaults,
)[0] )
if not created:
# We likely received a new follow when we had an existing one in database
# this can happen when two instances are out of sync, e.g because some
# messages are not delivered properly. In this case, we don't change
# the follow approved status and return the follow as is.
# We set a new UUID to ensure the follow urls are updated properly
# cf #830
follow.uuid = uuid.uuid4()
follow.save(update_fields=["uuid"])
return follow
# it's a brand new follow, we use the approved value stored earlier
if approved != follow.approved:
follow.approved = approved
follow.save(update_fields=["approved"])
return follow
def to_representation(self, instance): def to_representation(self, instance):
return { return {
"@context": AP_CONTEXT, "@context": jsonld.get_default_context(),
"actor": instance.actor.fid, "actor": instance.actor.fid,
"id": instance.get_federation_id(), "id": instance.get_federation_id(),
"object": instance.target.fid, "object": instance.target.fid,
...@@ -323,11 +717,10 @@ class APIFollowSerializer(serializers.ModelSerializer): ...@@ -323,11 +717,10 @@ class APIFollowSerializer(serializers.ModelSerializer):
] ]
class AcceptFollowSerializer(serializers.Serializer): class FollowActionSerializer(serializers.Serializer):
id = serializers.URLField(max_length=500, required=False) id = serializers.URLField(max_length=500, required=False)
actor = serializers.URLField(max_length=500) actor = serializers.URLField(max_length=500)
object = FollowSerializer() object = FollowSerializer()
type = serializers.ChoiceField(choices=["Accept"])
def validate_actor(self, v): def validate_actor(self, v):
expected = self.context.get("actor") expected = self.context.get("actor")
...@@ -355,12 +748,11 @@ class AcceptFollowSerializer(serializers.Serializer): ...@@ -355,12 +748,11 @@ class AcceptFollowSerializer(serializers.Serializer):
follow_class.objects.filter( follow_class.objects.filter(
target=target, actor=validated_data["object"]["actor"] target=target, actor=validated_data["object"]["actor"]
) )
.exclude(approved=True)
.select_related() .select_related()
.get() .get()
) )
except follow_class.DoesNotExist: except follow_class.DoesNotExist:
raise serializers.ValidationError("No follow to accept") raise serializers.ValidationError(f"No follow to {self.action_type}")
return validated_data return validated_data
def to_representation(self, instance): def to_representation(self, instance):
...@@ -370,19 +762,36 @@ class AcceptFollowSerializer(serializers.Serializer): ...@@ -370,19 +762,36 @@ class AcceptFollowSerializer(serializers.Serializer):
actor = instance.target actor = instance.target
return { return {
"@context": AP_CONTEXT, "@context": jsonld.get_default_context(),
"id": instance.get_federation_id() + "/accept", "id": instance.get_federation_id() + f"/{self.action_type}",
"type": "Accept", "type": self.action_type.title(),
"actor": actor.fid, "actor": actor.fid,
"object": FollowSerializer(instance).data, "object": FollowSerializer(instance).data,
} }
class AcceptFollowSerializer(FollowActionSerializer):
type = serializers.ChoiceField(choices=["Accept"])
action_type = "accept"
def save(self): def save(self):
follow = self.validated_data["follow"] follow = self.validated_data["follow"]
follow.approved = True follow.approved = True
follow.save() follow.save()
if follow.target._meta.label == "music.Library": if follow.target._meta.label == "music.Library":
follow.target.schedule_scan(actor=follow.actor) follow.target.schedule_scan(actor=follow.actor)
return follow
class RejectFollowSerializer(FollowActionSerializer):
type = serializers.ChoiceField(choices=["Reject"])
action_type = "reject"
def save(self):
follow = self.validated_data["follow"]
follow.approved = False
follow.save()
return follow return follow
...@@ -419,12 +828,14 @@ class UndoFollowSerializer(serializers.Serializer): ...@@ -419,12 +828,14 @@ class UndoFollowSerializer(serializers.Serializer):
actor=validated_data["actor"], target=target actor=validated_data["actor"], target=target
).get() ).get()
except follow_class.DoesNotExist: except follow_class.DoesNotExist:
raise serializers.ValidationError("No follow to remove") raise serializers.ValidationError(
f"No follow to remove follow_class = {follow_class}"
)
return validated_data return validated_data
def to_representation(self, instance): def to_representation(self, instance):
return { return {
"@context": AP_CONTEXT, "@context": jsonld.get_default_context(),
"id": instance.get_federation_id() + "/undo", "id": instance.get_federation_id() + "/undo",
"type": "Undo", "type": "Undo",
"actor": instance.actor.fid, "actor": instance.actor.fid,
...@@ -459,7 +870,7 @@ class ActorWebfingerSerializer(serializers.Serializer): ...@@ -459,7 +870,7 @@ class ActorWebfingerSerializer(serializers.Serializer):
def to_representation(self, instance): def to_representation(self, instance):
data = {} data = {}
data["subject"] = "acct:{}".format(instance.webfinger_subject) data["subject"] = f"acct:{instance.webfinger_subject}"
data["links"] = [ data["links"] = [
{"rel": "self", "href": instance.fid, "type": "application/activity+json"} {"rel": "self", "href": instance.fid, "type": "application/activity+json"}
] ]
...@@ -485,8 +896,7 @@ class ActivitySerializer(serializers.Serializer): ...@@ -485,8 +896,7 @@ class ActivitySerializer(serializers.Serializer):
try: try:
object_serializer = OBJECT_SERIALIZERS[type] object_serializer = OBJECT_SERIALIZERS[type]
except KeyError: except KeyError:
raise serializers.ValidationError("Unsupported type {}".format(type)) raise serializers.ValidationError(f"Unsupported type {type}")
serializer = object_serializer(data=value) serializer = object_serializer(data=value)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
return serializer.data return serializer.data
...@@ -504,7 +914,7 @@ class ActivitySerializer(serializers.Serializer): ...@@ -504,7 +914,7 @@ class ActivitySerializer(serializers.Serializer):
d.update(conf) d.update(conf)
if self.context.get("include_ap_context", True): if self.context.get("include_ap_context", True):
d["@context"] = AP_CONTEXT d["@context"] = jsonld.get_default_context()
return d return d
...@@ -537,10 +947,13 @@ OBJECT_SERIALIZERS = {t: ObjectSerializer for t in activity.OBJECT_TYPES} ...@@ -537,10 +947,13 @@ OBJECT_SERIALIZERS = {t: ObjectSerializer for t in activity.OBJECT_TYPES}
def get_additional_fields(data): def get_additional_fields(data):
UNSET = object() UNSET = object()
additional_fields = {} additional_fields = {}
for field in ["name", "summary"]: for field in ["name", "summary", "library", "audience", "published"]:
v = data.get(field, UNSET) v = data.get(field, UNSET)
if v == UNSET: if v == UNSET:
continue continue
# in some cases we use the serializer context to pass objects instances, we don't want to add them
if not isinstance(v, str) or isinstance(v, dict):
continue
additional_fields[field] = v additional_fields[field] = v
return additional_fields return additional_fields
...@@ -548,7 +961,6 @@ def get_additional_fields(data): ...@@ -548,7 +961,6 @@ def get_additional_fields(data):
PAGINATED_COLLECTION_JSONLD_MAPPING = { PAGINATED_COLLECTION_JSONLD_MAPPING = {
"totalItems": jsonld.first_val(contexts.AS.totalItems), "totalItems": jsonld.first_val(contexts.AS.totalItems),
"actor": jsonld.first_id(contexts.AS.actor),
"first": jsonld.first_id(contexts.AS.first), "first": jsonld.first_id(contexts.AS.first),
"last": jsonld.first_id(contexts.AS.last), "last": jsonld.first_id(contexts.AS.last),
"partOf": jsonld.first_id(contexts.AS.partOf), "partOf": jsonld.first_id(contexts.AS.partOf),
...@@ -556,9 +968,10 @@ PAGINATED_COLLECTION_JSONLD_MAPPING = { ...@@ -556,9 +968,10 @@ PAGINATED_COLLECTION_JSONLD_MAPPING = {
class PaginatedCollectionSerializer(jsonld.JsonLdSerializer): class PaginatedCollectionSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Collection]) type = serializers.ChoiceField(
choices=[contexts.AS.Collection, contexts.AS.OrderedCollection]
)
totalItems = serializers.IntegerField(min_value=0) totalItems = serializers.IntegerField(min_value=0)
actor = serializers.URLField(max_length=500)
id = serializers.URLField(max_length=500) id = serializers.URLField(max_length=500)
first = serializers.URLField(max_length=500) first = serializers.URLField(max_length=500)
last = serializers.URLField(max_length=500) last = serializers.URLField(max_length=500)
...@@ -568,31 +981,31 @@ class PaginatedCollectionSerializer(jsonld.JsonLdSerializer): ...@@ -568,31 +981,31 @@ class PaginatedCollectionSerializer(jsonld.JsonLdSerializer):
def to_representation(self, conf): def to_representation(self, conf):
paginator = Paginator(conf["items"], conf.get("page_size", 20)) paginator = Paginator(conf["items"], conf.get("page_size", 20))
first = funkwhale_utils.set_query_parameter(conf["id"], page=1) first = common_utils.set_query_parameter(conf["id"], page=1)
current = first current = first
last = funkwhale_utils.set_query_parameter(conf["id"], page=paginator.num_pages) last = common_utils.set_query_parameter(conf["id"], page=paginator.num_pages)
d = { data = {
"id": conf["id"], "id": conf["id"],
"actor": conf["actor"].fid, "attributedTo": conf["actor"].fid,
"totalItems": paginator.count, "totalItems": paginator.count,
"type": conf.get("type", "Collection"), "type": conf.get("type", "Collection"),
"current": current, "current": current,
"first": first, "first": first,
"last": last, "last": last,
} }
d.update(get_additional_fields(conf)) data.update(get_additional_fields(conf))
if self.context.get("include_ap_context", True): if self.context.get("include_ap_context", True):
d["@context"] = AP_CONTEXT data["@context"] = jsonld.get_default_context()
return d return data
class LibrarySerializer(PaginatedCollectionSerializer): class LibrarySerializer(PaginatedCollectionSerializer):
type = serializers.ChoiceField( type = serializers.ChoiceField(
choices=[contexts.AS.Collection, contexts.FW.Library] choices=[contexts.AS.Collection, contexts.FW.Library]
) )
actor = serializers.URLField(max_length=500, required=False)
attributedTo = serializers.URLField(max_length=500, required=False)
name = serializers.CharField() name = serializers.CharField()
summary = serializers.CharField(allow_blank=True, allow_null=True, required=False)
followers = serializers.URLField(max_length=500)
audience = serializers.ChoiceField( audience = serializers.ChoiceField(
choices=["", "./", None, "https://www.w3.org/ns/activitystreams#Public"], choices=["", "./", None, "https://www.w3.org/ns/activitystreams#Public"],
required=False, required=False,
...@@ -601,36 +1014,59 @@ class LibrarySerializer(PaginatedCollectionSerializer): ...@@ -601,36 +1014,59 @@ class LibrarySerializer(PaginatedCollectionSerializer):
) )
class Meta: class Meta:
jsonld_mapping = funkwhale_utils.concat_dicts( # not strictly necessary because it's not a model serializer
# but used by tasks.py/fetch
model = music_models.Library
jsonld_mapping = common_utils.concat_dicts(
PAGINATED_COLLECTION_JSONLD_MAPPING, PAGINATED_COLLECTION_JSONLD_MAPPING,
{ {
"name": jsonld.first_val(contexts.AS.name), "name": jsonld.first_val(contexts.AS.name),
"summary": jsonld.first_val(contexts.AS.summary),
"audience": jsonld.first_id(contexts.AS.audience), "audience": jsonld.first_id(contexts.AS.audience),
"followers": jsonld.first_id(contexts.AS.followers), "actor": jsonld.first_id(contexts.AS.actor),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
}, },
) )
def validate(self, validated_data):
d = super().validate(validated_data)
actor = d.get("actor")
attributed_to = d.get("attributedTo")
if not actor and not attributed_to:
raise serializers.ValidationError(
"You need to provide at least actor or attributedTo"
)
d["attributedTo"] = attributed_to or actor
return d
def to_representation(self, library): def to_representation(self, library):
conf = { conf = {
"id": library.fid, "id": library.fid,
"name": library.name, "name": library.name,
"summary": library.description,
"page_size": 100, "page_size": 100,
"attributedTo": library.actor,
"actor": library.actor, "actor": library.actor,
"items": library.uploads.for_federation(), "items": (
library.uploads.for_federation()
if not library.playlist_uploads.all()
else library.playlist_uploads.for_federation()
),
"type": "Library", "type": "Library",
} }
r = super().to_representation(conf) r = super().to_representation(conf)
r["audience"] = ( r["audience"] = (
contexts.AS.Public if library.privacy_level == "everyone" else "" contexts.AS.Public if library.privacy_level == "everyone" else ""
) )
r["followers"] = library.followers_url
return r return r
def create(self, validated_data): def create(self, validated_data):
if self.instance:
actor = self.instance.actor
else:
actor = utils.retrieve_ap_object( actor = utils.retrieve_ap_object(
validated_data["actor"], validated_data["attributedTo"],
actor=self.context.get("fetch_actor"),
queryset=models.Actor, queryset=models.Actor,
serializer_class=ActorSerializer, serializer_class=ActorSerializer,
) )
...@@ -641,19 +1077,22 @@ class LibrarySerializer(PaginatedCollectionSerializer): ...@@ -641,19 +1077,22 @@ class LibrarySerializer(PaginatedCollectionSerializer):
defaults={ defaults={
"uploads_count": validated_data["totalItems"], "uploads_count": validated_data["totalItems"],
"name": validated_data["name"], "name": validated_data["name"],
"description": validated_data["summary"],
"followers_url": validated_data["followers"],
"privacy_level": privacy[validated_data["audience"]], "privacy_level": privacy[validated_data["audience"]],
"uuid": validated_data["id"].rstrip("/").split("/")[-1],
}, },
) )
return library return library
def update(self, instance, validated_data):
return self.create(validated_data)
class CollectionPageSerializer(jsonld.JsonLdSerializer): class CollectionPageSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.CollectionPage]) type = serializers.ChoiceField(choices=[contexts.AS.CollectionPage])
totalItems = serializers.IntegerField(min_value=0) totalItems = serializers.IntegerField(min_value=0)
items = serializers.ListField() items = serializers.ListField()
actor = serializers.URLField(max_length=500) actor = serializers.URLField(max_length=500, required=False)
attributedTo = serializers.URLField(max_length=500, required=False)
id = serializers.URLField(max_length=500) id = serializers.URLField(max_length=500)
first = serializers.URLField(max_length=500) first = serializers.URLField(max_length=500)
last = serializers.URLField(max_length=500) last = serializers.URLField(max_length=500)
...@@ -666,10 +1105,11 @@ class CollectionPageSerializer(jsonld.JsonLdSerializer): ...@@ -666,10 +1105,11 @@ class CollectionPageSerializer(jsonld.JsonLdSerializer):
"totalItems": jsonld.first_val(contexts.AS.totalItems), "totalItems": jsonld.first_val(contexts.AS.totalItems),
"items": jsonld.raw(contexts.AS.items), "items": jsonld.raw(contexts.AS.items),
"actor": jsonld.first_id(contexts.AS.actor), "actor": jsonld.first_id(contexts.AS.actor),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
"first": jsonld.first_id(contexts.AS.first), "first": jsonld.first_id(contexts.AS.first),
"last": jsonld.first_id(contexts.AS.last), "last": jsonld.first_id(contexts.AS.last),
"next": jsonld.first_id(contexts.AS.next), "next": jsonld.first_id(contexts.AS.next),
"prev": jsonld.first_id(contexts.AS.next), "prev": jsonld.first_id(contexts.AS.prev),
"partOf": jsonld.first_id(contexts.AS.partOf), "partOf": jsonld.first_id(contexts.AS.partOf),
} }
...@@ -690,39 +1130,45 @@ class CollectionPageSerializer(jsonld.JsonLdSerializer): ...@@ -690,39 +1130,45 @@ class CollectionPageSerializer(jsonld.JsonLdSerializer):
def to_representation(self, conf): def to_representation(self, conf):
page = conf["page"] page = conf["page"]
first = funkwhale_utils.set_query_parameter(conf["id"], page=1) first = common_utils.set_query_parameter(conf["id"], page=1)
last = funkwhale_utils.set_query_parameter( last = common_utils.set_query_parameter(
conf["id"], page=page.paginator.num_pages conf["id"], page=page.paginator.num_pages
) )
id = funkwhale_utils.set_query_parameter(conf["id"], page=page.number) id = common_utils.set_query_parameter(conf["id"], page=page.number)
d = { d = {
"id": id, "id": id,
"partOf": conf["id"], "partOf": conf["id"],
"actor": conf["actor"].fid,
"totalItems": page.paginator.count, "totalItems": page.paginator.count,
"type": "CollectionPage", "type": "CollectionPage",
"first": first, "first": first,
"last": last, "last": last,
"items": [ "items": [
conf["item_serializer"]( conf["item_serializer"](
i, context={"actor": conf["actor"], "include_ap_context": False} i,
context={
"actor": conf["actor"],
"library": conf.get("library", None),
"include_ap_context": False,
},
).data ).data
for i in page.object_list for i in page.object_list
], ],
} }
if conf["actor"]:
d["attributedTo"] = conf["actor"].fid
if page.has_previous(): if page.has_previous():
d["prev"] = funkwhale_utils.set_query_parameter( d["prev"] = common_utils.set_query_parameter(
conf["id"], page=page.previous_page_number() conf["id"], page=page.previous_page_number()
) )
if page.has_next(): if page.has_next():
d["next"] = funkwhale_utils.set_query_parameter( d["next"] = common_utils.set_query_parameter(
conf["id"], page=page.next_page_number() conf["id"], page=page.next_page_number()
) )
d.update(get_additional_fields(conf)) d.update(get_additional_fields(conf))
if self.context.get("include_ap_context", True): if self.context.get("include_ap_context", True):
d["@context"] = AP_CONTEXT d["@context"] = jsonld.get_default_context()
return d return d
...@@ -730,7 +1176,36 @@ MUSIC_ENTITY_JSONLD_MAPPING = { ...@@ -730,7 +1176,36 @@ MUSIC_ENTITY_JSONLD_MAPPING = {
"name": jsonld.first_val(contexts.AS.name), "name": jsonld.first_val(contexts.AS.name),
"published": jsonld.first_val(contexts.AS.published), "published": jsonld.first_val(contexts.AS.published),
"musicbrainzId": jsonld.first_val(contexts.FW.musicbrainzId), "musicbrainzId": jsonld.first_val(contexts.FW.musicbrainzId),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
"tags": jsonld.raw(contexts.AS.tag),
"mediaType": jsonld.first_val(contexts.AS.mediaType),
"content": jsonld.first_val(contexts.AS.content),
}
def repr_tag(tag_name):
return {"type": "Hashtag", "name": f"#{tag_name}"}
def include_content(repr, content_obj):
if not content_obj:
return
repr["content"] = common_utils.render_html(
content_obj.text, content_obj.content_type
)
repr["mediaType"] = "text/html"
def include_image(repr, attachment, field="image"):
if attachment:
repr[field] = {
"type": "Image",
"url": attachment.download_url_original,
"mediaType": attachment.mimetype or "image/jpeg",
} }
else:
repr[field] = None
class MusicEntitySerializer(jsonld.JsonLdSerializer): class MusicEntitySerializer(jsonld.JsonLdSerializer):
...@@ -738,94 +1213,308 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer): ...@@ -738,94 +1213,308 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
published = serializers.DateTimeField() published = serializers.DateTimeField()
musicbrainzId = serializers.UUIDField(allow_null=True, required=False) musicbrainzId = serializers.UUIDField(allow_null=True, required=False)
name = serializers.CharField(max_length=1000) name = serializers.CharField(max_length=1000)
attributedTo = serializers.URLField(max_length=500, allow_null=True, required=False)
updateable_fields = []
tags = serializers.ListField(
child=TagSerializer(), min_length=0, required=False, allow_null=True
)
mediaType = serializers.ChoiceField(
choices=common_models.CONTENT_TEXT_SUPPORTED_TYPES,
default="text/html",
required=False,
)
content = TruncatedCharField(
truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH,
required=False,
allow_null=True,
)
def update(self, instance, validated_data):
return self.update_or_create(validated_data)
@transaction.atomic
def update_or_create(self, validated_data):
instance = self.instance or self.Meta.model(fid=validated_data["id"])
creating = instance.pk is None
attributed_to_fid = validated_data.get("attributedTo")
if attributed_to_fid:
validated_data["attributedTo"] = actors.get_actor(attributed_to_fid)
updated_fields = common_utils.get_updated_fields(
self.updateable_fields, validated_data, instance
)
updated_fields = self.validate_updated_data(instance, updated_fields)
class ArtistSerializer(MusicEntitySerializer): set_ac = False
class Meta: if "artist_credit" in updated_fields:
jsonld_mapping = MUSIC_ENTITY_JSONLD_MAPPING artist_credit = updated_fields.pop("artist_credit")
set_ac = True
def to_representation(self, instance): if creating:
d = { instance, created = self.Meta.model.objects.get_or_create(
"type": "Artist", fid=validated_data["id"], defaults=updated_fields
"id": instance.fid, )
"name": instance.name, if set_ac:
"published": instance.creation_date.isoformat(), instance.artist_credit.set(artist_credit)
"musicbrainzId": str(instance.mbid) if instance.mbid else None, else:
} obj = music_tasks.update_library_entity(instance, updated_fields)
if set_ac:
obj.artist_credit.set(artist_credit)
tags = [t["name"] for t in validated_data.get("tags", []) or []]
tags_models.set_tags(instance, *tags)
common_utils.attach_content(
instance, "description", validated_data.get("description")
)
return instance
if self.context.get("include_ap_context", self.parent is None): def get_tags_repr(self, instance):
d["@context"] = AP_CONTEXT return tag_list(instance.tagged_items.all())
return d
def validate_updated_data(self, instance, validated_data):
try:
attachment_cover = validated_data.pop("attachment_cover")
except KeyError:
return validated_data
class AlbumSerializer(MusicEntitySerializer): if (
released = serializers.DateField(allow_null=True, required=False) instance.attachment_cover
artists = serializers.ListField(child=ArtistSerializer(), min_length=1) and instance.attachment_cover.url == attachment_cover["url"]
cover = LinkSerializer( ):
allowed_mimetypes=["image/*"], allow_null=True, required=False # we already have the proper attachment
return validated_data
# create the attachment by hand so it can be attached as the cover
validated_data["attachment_cover"] = common_models.Attachment.objects.create(
mimetype=attachment_cover.get("mediaType"),
url=attachment_cover["url"],
actor=instance.attributed_to,
) )
return validated_data
class Meta: def validate(self, data):
jsonld_mapping = funkwhale_utils.concat_dicts( validated_data = super().validate(data)
MUSIC_ENTITY_JSONLD_MAPPING, if data.get("content"):
{ validated_data["description"] = {
"released": jsonld.first_val(contexts.FW.released), "content_type": data["mediaType"],
"artists": jsonld.first_attr(contexts.FW.artists, "@list"), "text": data["content"],
"cover": jsonld.first_obj(contexts.FW.cover), }
return validated_data
class ArtistSerializer(MusicEntitySerializer):
image = ImageSerializer(
allowed_mimetypes=["image/*"],
allow_null=True,
required=False,
allow_empty_mimetype=True,
)
updateable_fields = [
("name", "name"),
("musicbrainzId", "mbid"),
("attributedTo", "attributed_to"),
("image", "attachment_cover"),
]
class Meta:
model = music_models.Artist
jsonld_mapping = common_utils.concat_dicts(
MUSIC_ENTITY_JSONLD_MAPPING,
{
"released": jsonld.first_val(contexts.FW.released),
"image": jsonld.first_obj(contexts.AS.image),
}, },
) )
def to_representation(self, instance): def to_representation(self, instance):
d = { d = {
"type": "Album", "type": "Artist",
"id": instance.fid, "id": instance.fid,
"name": instance.title, "name": instance.name,
"published": instance.creation_date.isoformat(), "published": instance.creation_date.isoformat(),
"musicbrainzId": str(instance.mbid) if instance.mbid else None, "musicbrainzId": str(instance.mbid) if instance.mbid else None,
"released": instance.release_date.isoformat() "attributedTo": (
if instance.release_date instance.attributed_to.fid if instance.attributed_to else None
else None, ),
"artists": [ "tag": self.get_tags_repr(instance),
ArtistSerializer( }
include_content(d, instance.description)
include_image(d, instance.attachment_cover)
if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context()
return d
create = MusicEntitySerializer.update_or_create
class ArtistCreditSerializer(jsonld.JsonLdSerializer):
artist = ArtistSerializer()
joinphrase = serializers.CharField(
trim_whitespace=False, required=False, allow_null=True, allow_blank=True
)
credit = serializers.CharField(
trim_whitespace=False, required=False, allow_null=True, allow_blank=True
)
published = serializers.DateTimeField()
id = serializers.URLField(max_length=500)
updateable_fields = [
("credit", "credit"),
("artist", "artist"),
("joinphrase", "joinphrase"),
]
class Meta:
model = music_models.ArtistCredit
jsonld_mapping = {
"artist": jsonld.first_obj(contexts.FW.artist),
"credit": jsonld.first_val(contexts.FW.credit),
"index": jsonld.first_val(contexts.FW.index),
"joinphrase": jsonld.first_val(contexts.FW.joinphrase),
"published": jsonld.first_val(contexts.AS.published),
}
def to_representation(self, instance):
data = {
"type": "ArtistCredit",
"id": instance.fid,
"artist": ArtistSerializer(
instance.artist, context={"include_ap_context": False} instance.artist, context={"include_ap_context": False}
).data ).data,
], "joinphrase": instance.joinphrase,
"credit": instance.credit,
"index": instance.index,
"published": instance.creation_date.isoformat(),
} }
if instance.cover: if self.context.get("include_ap_context", self.parent is None):
d["cover"] = { data["@context"] = jsonld.get_default_context()
"type": "Link", return data
"href": utils.full_url(instance.cover.url),
"mediaType": mimetypes.guess_type(instance.cover.path)[0]
or "image/jpeg", class AlbumSerializer(MusicEntitySerializer):
released = serializers.DateField(allow_null=True, required=False)
artist_credit = serializers.ListField(child=ArtistCreditSerializer(), min_length=1)
image = ImageSerializer(
allowed_mimetypes=["image/*"],
allow_null=True,
required=False,
allow_empty_mimetype=True,
)
updateable_fields = [
("name", "title"),
("image", "attachment_cover"),
("musicbrainzId", "mbid"),
("attributedTo", "attributed_to"),
("released", "release_date"),
("artist_credit", "artist_credit"),
]
class Meta:
model = music_models.Album
jsonld_mapping = common_utils.concat_dicts(
MUSIC_ENTITY_JSONLD_MAPPING,
{
"released": jsonld.first_val(contexts.FW.released),
"artist_credit": jsonld.first_attr(contexts.FW.artist_credit, "@list"),
"image": jsonld.first_obj(contexts.AS.image),
},
)
def to_representation(self, instance):
data = {
"type": "Album",
"id": instance.fid,
"name": instance.title,
"published": instance.creation_date.isoformat(),
"musicbrainzId": str(instance.mbid) if instance.mbid else None,
"released": (
instance.release_date.isoformat() if instance.release_date else None
),
"attributedTo": (
instance.attributed_to.fid if instance.attributed_to else None
),
"tag": self.get_tags_repr(instance),
} }
data["artist_credit"] = ArtistCreditSerializer(
instance.artist_credit.all(),
context={"include_ap_context": False},
many=True,
).data
include_content(data, instance.description)
if instance.attachment_cover:
include_image(data, instance.attachment_cover)
if self.context.get("include_ap_context", self.parent is None): if self.context.get("include_ap_context", self.parent is None):
d["@context"] = AP_CONTEXT data["@context"] = jsonld.get_default_context()
return d return data
def validate(self, data):
validated_data = super().validate(data)
if not self.parent:
artist_credit_data = validated_data["artist_credit"]
if artist_credit_data[0]["artist"].get("type", "Artist") == "Artist":
acs = []
for ac in validated_data["artist_credit"]:
acs.append(
utils.retrieve_ap_object(
ac["id"],
actor=self.context.get("fetch_actor"),
queryset=music_models.ArtistCredit,
serializer_class=ArtistCreditSerializer,
)
)
validated_data["artist_credit"] = acs
else:
# we have an actor as an artist, so it's a channel
actor = actors.get_actor(artist_credit_data[0]["artist"]["id"])
validated_data["artist_credit"] = [{"artist": actor.channel.artist}]
return validated_data
create = MusicEntitySerializer.update_or_create
class TrackSerializer(MusicEntitySerializer): class TrackSerializer(MusicEntitySerializer):
position = serializers.IntegerField(min_value=0, allow_null=True, required=False) position = serializers.IntegerField(min_value=0, allow_null=True, required=False)
disc = serializers.IntegerField(min_value=1, allow_null=True, required=False) disc = serializers.IntegerField(min_value=1, allow_null=True, required=False)
artists = serializers.ListField(child=ArtistSerializer(), min_length=1) artist_credit = serializers.ListField(child=ArtistCreditSerializer(), min_length=1)
album = AlbumSerializer() album = AlbumSerializer()
license = serializers.URLField(allow_null=True, required=False) license = serializers.URLField(allow_null=True, required=False)
copyright = serializers.CharField(allow_null=True, required=False) copyright = serializers.CharField(allow_null=True, required=False)
image = ImageSerializer(
allowed_mimetypes=["image/*"],
allow_null=True,
required=False,
allow_empty_mimetype=True,
)
updateable_fields = [
("name", "title"),
("musicbrainzId", "mbid"),
("attributedTo", "attributed_to"),
("disc", "disc_number"),
("position", "position"),
("copyright", "copyright"),
("license", "license"),
("image", "attachment_cover"),
]
class Meta: class Meta:
jsonld_mapping = funkwhale_utils.concat_dicts( model = music_models.Track
jsonld_mapping = common_utils.concat_dicts(
MUSIC_ENTITY_JSONLD_MAPPING, MUSIC_ENTITY_JSONLD_MAPPING,
{ {
"album": jsonld.first_obj(contexts.FW.album), "album": jsonld.first_obj(contexts.FW.album),
"artists": jsonld.first_attr(contexts.FW.artists, "@list"), "artist_credit": jsonld.first_attr(contexts.FW.artist_credit, "@list"),
"copyright": jsonld.first_val(contexts.FW.copyright), "copyright": jsonld.first_val(contexts.FW.copyright),
"disc": jsonld.first_val(contexts.FW.disc), "disc": jsonld.first_val(contexts.FW.disc),
"license": jsonld.first_id(contexts.FW.license), "license": jsonld.first_id(contexts.FW.license),
"position": jsonld.first_val(contexts.FW.position), "position": jsonld.first_val(contexts.FW.position),
"image": jsonld.first_obj(contexts.AS.image),
}, },
) )
def to_representation(self, instance): def to_representation(self, instance):
d = { data = {
"type": "Track", "type": "Track",
"id": instance.fid, "id": instance.fid,
"name": instance.title, "name": instance.title,
...@@ -833,34 +1522,128 @@ class TrackSerializer(MusicEntitySerializer): ...@@ -833,34 +1522,128 @@ class TrackSerializer(MusicEntitySerializer):
"musicbrainzId": str(instance.mbid) if instance.mbid else None, "musicbrainzId": str(instance.mbid) if instance.mbid else None,
"position": instance.position, "position": instance.position,
"disc": instance.disc_number, "disc": instance.disc_number,
"license": instance.local_license["identifiers"][0] "license": (
instance.local_license["identifiers"][0]
if instance.local_license if instance.local_license
else None, else None
),
"copyright": instance.copyright if instance.copyright else None, "copyright": instance.copyright if instance.copyright else None,
"artists": [ "artist_credit": ArtistCreditSerializer(
ArtistSerializer( instance.artist_credit.all(),
instance.artist, context={"include_ap_context": False} context={"include_ap_context": False},
).data many=True,
], ).data,
"album": AlbumSerializer( "album": AlbumSerializer(
instance.album, context={"include_ap_context": False} instance.album, context={"include_ap_context": False}
).data, ).data,
"attributedTo": (
instance.attributed_to.fid if instance.attributed_to else None
),
"tag": self.get_tags_repr(instance),
} }
include_content(data, instance.description)
include_image(data, instance.attachment_cover)
if self.context.get("include_ap_context", self.parent is None): if self.context.get("include_ap_context", self.parent is None):
d["@context"] = AP_CONTEXT data["@context"] = jsonld.get_default_context()
return d return data
@transaction.atomic
def create(self, validated_data): def create(self, validated_data):
from funkwhale_api.music import tasks as music_tasks from funkwhale_api.music import tasks as music_tasks
metadata = music_tasks.federation_audio_track_to_metadata(validated_data) references = {}
actors_to_fetch = set()
actors_to_fetch.add(
common_utils.recursive_getattr(
validated_data, "attributedTo", permissive=True
)
)
actors_to_fetch.add(
common_utils.recursive_getattr(
validated_data, "album.attributedTo", permissive=True
)
)
artist_credit = (
common_utils.recursive_getattr(
validated_data, "artist_credit", permissive=True
)
or []
)
album_artists_credit = (
common_utils.recursive_getattr(
validated_data, "album.artist_credit", permissive=True
)
or []
)
for ac in artist_credit + album_artists_credit:
actors_to_fetch.add(ac["artist"].get("attributedTo"))
for url in actors_to_fetch:
if not url:
continue
references[url] = actors.get_actor(url)
metadata = music_tasks.federation_audio_track_to_metadata(
validated_data, references
)
from_activity = self.context.get("activity") from_activity = self.context.get("activity")
if from_activity: if from_activity:
metadata["from_activity_id"] = from_activity.pk metadata["from_activity_id"] = from_activity.pk
track = music_tasks.get_track_from_import_metadata(metadata) track = music_tasks.get_track_from_import_metadata(
metadata, update_cover=True, query_mb=False
)
return track return track
def update(self, obj, validated_data):
if validated_data.get("license"):
validated_data["license"] = licenses.match(validated_data["license"])
return super().update(obj, validated_data)
def duration_int_to_xml(duration):
if not duration:
return None
multipliers = {"S": 1, "M": 60, "H": 3600, "D": 86400}
ret = "P"
days, seconds = divmod(int(duration), multipliers["D"])
ret += f"{days:d}DT" if days > 0 else "T"
hours, seconds = divmod(seconds, multipliers["H"])
ret += f"{hours:d}H" if hours > 0 else ""
minutes, seconds = divmod(seconds, multipliers["M"])
ret += f"{minutes:d}M" if minutes > 0 else ""
ret += f"{seconds:d}S" if seconds > 0 or ret == "PT" else ""
return ret
class DayTimeDurationSerializer(serializers.DurationField):
multipliers = {"S": 1, "M": 60, "H": 3600, "D": 86400}
def to_internal_value(self, value):
if isinstance(value, float):
return value
parsed = re.match(
r"P([0-9]+D)?T([0-9]+H)?([0-9]+M)?([0-9]+(?:\.[0-9]+)?S)?", str(value)
)
if parsed is not None:
return int(
sum(
[
self.multipliers[s[-1]] * float("0" + s[:-1])
for s in parsed.groups()
if s is not None
]
)
)
self.fail(
"invalid", format="https://www.w3.org/TR/xmlschema11-2/#dayTimeDuration"
)
def to_representation(self, value):
duration_int_to_xml(value)
class UploadSerializer(jsonld.JsonLdSerializer): class UploadSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Audio]) type = serializers.ChoiceField(choices=[contexts.AS.Audio])
...@@ -871,11 +1654,12 @@ class UploadSerializer(jsonld.JsonLdSerializer): ...@@ -871,11 +1654,12 @@ class UploadSerializer(jsonld.JsonLdSerializer):
updated = serializers.DateTimeField(required=False, allow_null=True) updated = serializers.DateTimeField(required=False, allow_null=True)
bitrate = serializers.IntegerField(min_value=0) bitrate = serializers.IntegerField(min_value=0)
size = serializers.IntegerField(min_value=0) size = serializers.IntegerField(min_value=0)
duration = serializers.IntegerField(min_value=0) duration = DayTimeDurationSerializer(min_value=0)
track = TrackSerializer(required=True) track = TrackSerializer(required=True)
class Meta: class Meta:
model = music_models.Upload
jsonld_mapping = { jsonld_mapping = {
"track": jsonld.first_obj(contexts.FW.track), "track": jsonld.first_obj(contexts.FW.track),
"library": jsonld.first_id(contexts.FW.library), "library": jsonld.first_id(contexts.FW.library),
...@@ -906,25 +1690,54 @@ class UploadSerializer(jsonld.JsonLdSerializer): ...@@ -906,25 +1690,54 @@ class UploadSerializer(jsonld.JsonLdSerializer):
def validate_library(self, v): def validate_library(self, v):
lb = self.context.get("library") lb = self.context.get("library")
if lb: if lb:
if lb.fid != v: # the upload can come from a playlist lib
raise serializers.ValidationError("Invalid library") if lb.fid != v and not lb.playlist.library and lb.playlist.library.fid != v:
raise serializers.ValidationError("Invalid library fid")
return lb return lb
actor = self.context.get("actor") actor = self.context.get("actor")
kwargs = {}
if actor:
kwargs["actor"] = actor
try: try:
return music_models.Library.objects.get(fid=v, **kwargs) library = utils.retrieve_ap_object(
except music_models.Library.DoesNotExist: v,
raise serializers.ValidationError("Invalid library") actor=self.context.get("fetch_actor"),
queryset=music_models.Library,
serializer_class=LibrarySerializer,
)
except Exception as e:
raise serializers.ValidationError(f"Invalid library : {e}")
if actor and library.actor != actor:
raise serializers.ValidationError("Invalid library, actor check fails")
return library
def update(self, instance, validated_data):
return self.create(validated_data)
@transaction.atomic
def create(self, validated_data): def create(self, validated_data):
instance = self.instance or None
if not self.instance:
try: try:
return music_models.Upload.objects.get(fid=validated_data["id"]) instance = music_models.Upload.objects.get(fid=validated_data["id"])
except music_models.Upload.DoesNotExist: except music_models.Upload.DoesNotExist:
pass pass
if instance:
data = {
"mimetype": validated_data["url"]["mediaType"],
"source": validated_data["url"]["href"],
"creation_date": validated_data["published"],
"modification_date": validated_data.get("updated"),
"duration": validated_data["duration"],
"size": validated_data["size"],
"bitrate": validated_data["bitrate"],
"import_status": "finished",
"library": validated_data["library"],
}
return music_models.Upload.objects.update_or_create(
fid=validated_data["id"], defaults=data
)[0]
else:
track = TrackSerializer( track = TrackSerializer(
context={"activity": self.context.get("activity")} context={"activity": self.context.get("activity")}
).create(validated_data["track"]) ).create(validated_data["track"])
...@@ -946,28 +1759,123 @@ class UploadSerializer(jsonld.JsonLdSerializer): ...@@ -946,28 +1759,123 @@ class UploadSerializer(jsonld.JsonLdSerializer):
return music_models.Upload.objects.create(**data) return music_models.Upload.objects.create(**data)
def to_representation(self, instance): def to_representation(self, instance):
lib = instance.library if instance.library else self.context.get("library")
track = instance.track track = instance.track
d = { d = {
"type": "Audio", "type": "Audio",
"id": instance.get_federation_id(), "id": instance.get_federation_id(),
"library": instance.library.fid, "library": lib.fid,
"name": track.full_name, "name": track.full_name,
"published": instance.creation_date.isoformat(), "published": instance.creation_date.isoformat(),
"bitrate": instance.bitrate, "bitrate": instance.bitrate,
"size": instance.size, "size": instance.size,
"duration": instance.duration, "duration": duration_int_to_xml(instance.duration),
"url": { "url": [
"href": utils.full_url(instance.listen_url), {
"href": utils.full_url(instance.listen_url_no_download),
"type": "Link", "type": "Link",
"mediaType": instance.mimetype, "mediaType": instance.mimetype,
}, },
{
"type": "Link",
"mediaType": "text/html",
"href": utils.full_url(instance.track.get_absolute_url()),
},
],
"track": TrackSerializer(track, context={"include_ap_context": False}).data, "track": TrackSerializer(track, context={"include_ap_context": False}).data,
"to": (contexts.AS.Public if lib.privacy_level == "everyone" else ""),
"attributedTo": lib.actor.fid,
} }
if instance.modification_date: if instance.modification_date:
d["updated"] = instance.modification_date.isoformat() d["updated"] = instance.modification_date.isoformat()
if self.context.get("include_ap_context", self.parent is None): if self.context.get("include_ap_context", self.parent is None):
d["@context"] = AP_CONTEXT d["@context"] = jsonld.get_default_context()
return d
class ActorDeleteSerializer(jsonld.JsonLdSerializer):
fid = serializers.URLField(max_length=500)
class Meta:
jsonld_mapping = {"fid": jsonld.first_id(contexts.AS.object)}
class FlagSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Flag])
id = serializers.URLField(max_length=500)
object = serializers.URLField(max_length=500)
content = serializers.CharField(required=False, allow_null=True, allow_blank=True)
actor = serializers.URLField(max_length=500)
type = serializers.ListField(
child=TagSerializer(), min_length=0, required=False, allow_null=True
)
class Meta:
jsonld_mapping = {
"object": jsonld.first_id(contexts.AS.object),
"content": jsonld.first_val(contexts.AS.content),
"actor": jsonld.first_id(contexts.AS.actor),
"type": jsonld.raw(contexts.AS.tag),
}
def validate_object(self, v):
try:
return utils.get_object_by_fid(v, local=True)
except ObjectDoesNotExist:
raise serializers.ValidationError(f"Unknown id {v} for reported object")
def validate_type(self, tags):
if tags:
for tag in tags:
if tag["name"] in dict(moderation_models.REPORT_TYPES):
return tag["name"]
return "other"
def validate_actor(self, v):
try:
return models.Actor.objects.get(fid=v, domain=self.context["actor"].domain)
except models.Actor.DoesNotExist:
raise serializers.ValidationError("Invalid actor")
def validate(self, data):
validated_data = super().validate(data)
return validated_data
def create(self, validated_data):
kwargs = {
"target": validated_data["object"],
"target_owner": moderation_serializers.get_target_owner(
validated_data["object"]
),
"target_state": moderation_serializers.get_target_state(
validated_data["object"]
),
"type": validated_data.get("type", "other"),
"summary": validated_data.get("content"),
"submitter": validated_data["actor"],
}
report, created = moderation_models.Report.objects.update_or_create(
fid=validated_data["id"],
defaults=kwargs,
)
moderation_signals.report_created.send(sender=None, report=report)
return report
def to_representation(self, instance):
d = {
"type": "Flag",
"id": instance.get_federation_id(),
"actor": actors.get_service_actor().fid,
"object": [instance.target.fid],
"content": instance.summary,
"tag": [repr_tag(instance.type)],
}
if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context()
return d return d
...@@ -978,3 +1886,660 @@ class NodeInfoLinkSerializer(serializers.Serializer): ...@@ -978,3 +1886,660 @@ class NodeInfoLinkSerializer(serializers.Serializer):
class NodeInfoSerializer(serializers.Serializer): class NodeInfoSerializer(serializers.Serializer):
links = serializers.ListField(child=NodeInfoLinkSerializer(), min_length=1) links = serializers.ListField(child=NodeInfoLinkSerializer(), min_length=1)
class ChannelOutboxSerializer(PaginatedCollectionSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.OrderedCollection])
class Meta:
jsonld_mapping = PAGINATED_COLLECTION_JSONLD_MAPPING
def to_representation(self, channel):
conf = {
"id": channel.actor.outbox_url,
"page_size": 100,
"attributedTo": channel.actor,
"actor": channel.actor,
"items": channel.library.uploads.for_federation()
.order_by("-creation_date")
.filter(track__artist_credit__artist=channel.artist),
"type": "OrderedCollection",
}
r = super().to_representation(conf)
return r
class ChannelUploadSerializer(jsonld.JsonLdSerializer):
id = serializers.URLField(max_length=500)
type = serializers.ChoiceField(choices=[contexts.AS.Audio])
url = LinkListSerializer(keep_mediatype=["audio/*"], min_length=1)
name = serializers.CharField()
published = serializers.DateTimeField(required=False)
duration = DayTimeDurationSerializer(required=False)
position = serializers.IntegerField(min_value=0, allow_null=True, required=False)
disc = serializers.IntegerField(min_value=1, allow_null=True, required=False)
album = serializers.URLField(max_length=500, required=False)
license = serializers.URLField(allow_null=True, required=False)
attributedTo = serializers.URLField(max_length=500, required=False)
copyright = serializers.CharField(
allow_null=True,
required=False,
)
image = ImageSerializer(
allowed_mimetypes=["image/*"],
allow_null=True,
required=False,
allow_empty_mimetype=True,
)
mediaType = serializers.ChoiceField(
choices=common_models.CONTENT_TEXT_SUPPORTED_TYPES,
default="text/html",
required=False,
)
content = TruncatedCharField(
truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH,
required=False,
allow_blank=True,
allow_null=True,
)
tags = serializers.ListField(
child=TagSerializer(), min_length=0, required=False, allow_null=True
)
class Meta:
jsonld_mapping = {
"name": jsonld.first_val(contexts.AS.name),
"url": jsonld.raw(contexts.AS.url),
"published": jsonld.first_val(contexts.AS.published),
"mediaType": jsonld.first_val(contexts.AS.mediaType),
"content": jsonld.first_val(contexts.AS.content),
"duration": jsonld.first_val(contexts.AS.duration),
"album": jsonld.first_id(contexts.FW.album),
"copyright": jsonld.first_val(contexts.FW.copyright),
"disc": jsonld.first_val(contexts.FW.disc),
"license": jsonld.first_id(contexts.FW.license),
"position": jsonld.first_val(contexts.FW.position),
"image": jsonld.first_obj(contexts.AS.image),
"tags": jsonld.raw(contexts.AS.tag),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
}
def _validate_album(self, v):
return utils.retrieve_ap_object(
v,
actor=actors.get_service_actor(),
serializer_class=AlbumSerializer,
queryset=music_models.Album.objects.filter(
artist_credit__artist__channel=self.context["channel"]
),
)
def validate(self, data):
if not self.context.get("channel"):
if not data.get("attributedTo"):
raise serializers.ValidationError(
"Missing channel context and no attributedTo available"
)
actor = actors.get_actor(data["attributedTo"])
if not actor.get_channel():
raise serializers.ValidationError("Not a channel")
self.context["channel"] = actor.get_channel()
if data.get("album"):
data["album"] = self._validate_album(data["album"])
validated_data = super().validate(data)
if data.get("content"):
validated_data["description"] = {
"content_type": data["mediaType"],
"text": data["content"],
}
return validated_data
def to_representation(self, upload):
data = {
"id": upload.fid,
"type": "Audio",
"name": upload.track.title,
"attributedTo": upload.library.channel.actor.fid,
"published": upload.creation_date.isoformat(),
"to": (
contexts.AS.Public if upload.library.privacy_level == "everyone" else ""
),
"url": [
{
"type": "Link",
"mediaType": "text/html",
"href": utils.full_url(upload.track.get_absolute_url()),
},
{
"type": "Link",
"mediaType": upload.mimetype,
"href": utils.full_url(upload.listen_url_no_download),
},
],
}
if upload.track.album:
data["album"] = upload.track.album.fid
if upload.track.local_license:
data["license"] = upload.track.local_license["identifiers"][0]
include_if_not_none(data, duration_int_to_xml(upload.duration), "duration")
include_if_not_none(data, upload.track.position, "position")
include_if_not_none(data, upload.track.disc_number, "disc")
include_if_not_none(data, upload.track.copyright, "copyright")
include_if_not_none(data["url"][1], upload.bitrate, "bitrate")
include_if_not_none(data["url"][1], upload.size, "size")
include_content(data, upload.track.description)
include_image(data, upload.track.attachment_cover)
tags = [item.tag.name for item in upload.get_all_tagged_items()]
if tags:
data["tag"] = [repr_tag(name) for name in sorted(set(tags))]
data["summary"] = " ".join([f"#{name}" for name in tags])
if self.context.get("include_ap_context", True):
data["@context"] = jsonld.get_default_context()
return data
def update(self, instance, validated_data):
return self.update_or_create(validated_data)
@transaction.atomic
def update_or_create(self, validated_data):
channel = self.context["channel"]
now = timezone.now()
track_defaults = {
"fid": validated_data["id"],
"position": validated_data.get("position", 1),
"disc_number": validated_data.get("disc", 1),
"title": validated_data["name"],
"copyright": validated_data.get("copyright"),
"attributed_to": channel.attributed_to,
"album": validated_data.get("album"),
"creation_date": validated_data.get("published", now),
}
if validated_data.get("license"):
track_defaults["license"] = licenses.match(validated_data["license"])
track, created = music_models.Track.objects.update_or_create(
fid=validated_data["id"],
defaults=track_defaults,
)
# only one artist_credit per channel
query = (
Q(
artist=channel.artist,
)
& Q(credit__iexact=channel.artist.name)
& Q(joinphrase="")
)
defaults = {
"artist": channel.artist,
"joinphrase": "",
"credit": channel.artist.name,
}
ac_obj = music_tasks.get_best_candidate_or_create(
music_models.ArtistCredit,
query,
defaults=defaults,
sort_fields=["mbid", "fid"],
)
track.artist_credit.set([ac_obj[0].id])
if "image" in validated_data:
new_value = self.validated_data["image"]
common_utils.attach_file(
track,
"attachment_cover",
(
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value
else None
),
)
common_utils.attach_content(
track, "description", validated_data.get("description")
)
tags = [t["name"] for t in validated_data.get("tags", []) or []]
tags_models.set_tags(track, *tags)
upload_defaults = {
"fid": validated_data["id"],
"track": track,
"library": channel.library,
"creation_date": validated_data.get("published", now),
"duration": validated_data.get("duration"),
"bitrate": validated_data["url"][0].get("bitrate"),
"size": validated_data["url"][0].get("size"),
"mimetype": validated_data["url"][0]["mediaType"],
"source": validated_data["url"][0]["href"],
"import_status": "finished",
}
upload, created = music_models.Upload.objects.update_or_create(
fid=validated_data["id"], defaults=upload_defaults
)
return upload
def create(self, validated_data):
return self.update_or_create(validated_data)
class ChannelCreateUploadSerializer(jsonld.JsonLdSerializer):
object = serializers.DictField()
class Meta:
jsonld_mapping = {
"object": jsonld.first_obj(contexts.AS.object),
}
def to_representation(self, upload):
payload = {
"@context": jsonld.get_default_context(),
"type": self.context.get("type", "Create"),
"id": utils.full_url(
reverse(
"federation:music:uploads-activity", kwargs={"uuid": upload.uuid}
)
),
"actor": upload.library.channel.actor.fid,
"object": ChannelUploadSerializer(
upload, context={"include_ap_context": False}
).data,
}
if self.context.get("activity_id_suffix"):
payload["id"] = os.path.join(
payload["id"], self.context["activity_id_suffix"]
)
return payload
def validate(self, validated_data):
serializer = ChannelUploadSerializer(
data=validated_data["object"], context=self.context, jsonld_expand=False
)
serializer.is_valid(raise_exception=True)
return {"audio_serializer": serializer}
def save(self, **kwargs):
return self.validated_data["audio_serializer"].save(**kwargs)
class DeleteSerializer(jsonld.JsonLdSerializer):
object = serializers.URLField(max_length=500)
type = serializers.ChoiceField(choices=[contexts.AS.Delete])
class Meta:
jsonld_mapping = {"object": jsonld.first_id(contexts.AS.object)}
def validate_object(self, url):
try:
obj = utils.get_object_by_fid(url)
except utils.ObjectDoesNotExist:
raise serializers.ValidationError(f"No object matching {url}")
if isinstance(obj, music_models.Upload):
obj = obj.track
return obj
def validate(self, validated_data):
if not utils.can_manage(
validated_data["object"].attributed_to, self.context["actor"]
):
raise serializers.ValidationError("You cannot delete this object")
return validated_data
class IndexSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(
choices=[contexts.AS.Collection, contexts.AS.OrderedCollection]
)
totalItems = serializers.IntegerField(min_value=0)
id = serializers.URLField(max_length=500)
first = serializers.URLField(max_length=500)
last = serializers.URLField(max_length=500)
class Meta:
jsonld_mapping = PAGINATED_COLLECTION_JSONLD_MAPPING
def to_representation(self, conf):
paginator = Paginator(conf["items"], conf["page_size"])
first = common_utils.set_query_parameter(conf["id"], page=1)
current = first
last = common_utils.set_query_parameter(conf["id"], page=paginator.num_pages)
d = {
"id": conf["id"],
"totalItems": paginator.count,
"type": "OrderedCollection",
"current": current,
"first": first,
"last": last,
}
if self.context.get("include_ap_context", True):
d["@context"] = jsonld.get_default_context()
return d
class TrackFavoriteSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Like])
id = serializers.URLField(max_length=500)
object = serializers.URLField(max_length=500)
actor = serializers.URLField(max_length=500)
audience = serializers.CharField(max_length=500)
class Meta:
jsonld_mapping = {
"object": jsonld.first_id(contexts.AS.object),
"actor": jsonld.first_id(contexts.AS.actor),
"audience": jsonld.first_id(contexts.AS.audience),
}
def to_representation(self, favorite):
payload = {
"type": "Like",
"id": favorite.fid,
"actor": favorite.actor.fid,
"object": favorite.track.fid,
"audience": favorite.privacy_level,
}
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
return payload
def create(self, validated_data):
actor = actors.get_actor(validated_data["actor"])
track = utils.retrieve_ap_object(
validated_data["object"],
actor=actors.get_service_actor(),
serializer_class=TrackSerializer,
)
return favorites_models.TrackFavorite.objects.create(
fid=validated_data.get("id"),
uuid=uuid.uuid4(),
actor=actor,
track=track,
privacy_level=validated_data["audience"],
)
class ListeningSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Listen])
id = serializers.URLField(max_length=500)
object = serializers.URLField(max_length=500)
actor = serializers.URLField(max_length=500)
audience = serializers.CharField(max_length=500)
class Meta:
jsonld_mapping = {
"object": jsonld.first_id(contexts.AS.object),
"actor": jsonld.first_id(contexts.AS.actor),
"audience": jsonld.first_id(contexts.AS.audience),
}
def to_representation(self, listening):
payload = {
"type": "Listen",
"id": listening.fid,
"actor": listening.actor.fid,
"object": listening.track.fid,
"audience": listening.privacy_level,
}
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
return payload
def create(self, validated_data):
actor = actors.get_actor(validated_data["actor"])
track = utils.retrieve_ap_object(
validated_data["object"],
actor=actors.get_service_actor(),
serializer_class=TrackSerializer,
)
return history_models.Listening.objects.create(
fid=validated_data.get("id"),
uuid=validated_data["id"].rstrip("/").split("/")[-1],
actor=actor,
track=track,
privacy_level=validated_data["audience"],
)
class PlaylistTrackSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.FW.PlaylistTrack])
id = serializers.URLField(max_length=500)
track = serializers.URLField(max_length=500)
index = serializers.IntegerField()
creation_date = serializers.DateTimeField()
playlist = serializers.URLField(max_length=500, required=False)
class Meta:
model = playlists_models.PlaylistTrack
jsonld_mapping = {
"track": jsonld.first_id(contexts.FW.track),
"playlist": jsonld.first_id(contexts.FW.playlist),
"index": jsonld.first_val(contexts.FW.index),
"creation_date": jsonld.first_val(contexts.AS.published),
}
def to_representation(self, plt):
payload = {
"type": "PlaylistTrack",
"id": plt.fid,
"track": plt.track.fid,
"index": plt.index,
"attributedTo": plt.playlist.actor.fid,
"published": plt.creation_date.isoformat(),
}
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
if self.context.get("include_playlist", True):
payload["playlist"] = plt.playlist.fid
return payload
def create(self, validated_data):
track = utils.retrieve_ap_object(
validated_data["track"],
actor=self.context.get("fetch_actor"),
queryset=music_models.Track,
serializer_class=TrackSerializer,
)
playlist = utils.retrieve_ap_object(
validated_data["playlist"],
actor=self.context.get("fetch_actor"),
queryset=playlists_models.Playlist,
serializer_class=PlaylistSerializer,
)
defaults = {
"track": track,
"index": validated_data["index"],
"creation_date": validated_data["creation_date"],
"playlist": playlist,
}
if existing_plt := playlists_models.PlaylistTrack.objects.filter(
playlist=playlist, index=validated_data["index"]
):
existing_plt.delete()
plt, created = playlists_models.PlaylistTrack.objects.update_or_create(
defaults,
**{
"uuid": validated_data["id"].rstrip("/").split("/")[-1],
"fid": validated_data["id"],
},
)
return plt
class PlaylistSerializer(jsonld.JsonLdSerializer):
"""
Used for playlist activities
"""
type = serializers.ChoiceField(choices=[contexts.FW.Playlist, contexts.AS.Create])
id = serializers.URLField(max_length=500)
uuid = serializers.UUIDField(required=False)
name = serializers.CharField(required=False)
attributedTo = serializers.URLField(max_length=500, required=False)
published = serializers.DateTimeField(required=False)
updated = serializers.DateTimeField(required=False)
audience = serializers.ChoiceField(
choices=fields.PRIVACY_LEVEL_CHOICES,
)
library = serializers.URLField(max_length=500, required=True)
updateable_fields = [
("name", "title"),
("attributedTo", "attributed_to"),
]
class Meta:
model = playlists_models.Playlist
jsonld_mapping = common_utils.concat_dicts(
MUSIC_ENTITY_JSONLD_MAPPING,
{
"updated": jsonld.first_val(contexts.AS.published),
"audience": jsonld.first_id(contexts.AS.audience),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
"library": jsonld.first_id(contexts.FW.library),
},
)
def to_representation(self, playlist):
payload = {
"type": "Playlist",
"id": playlist.fid,
"name": playlist.name,
"attributedTo": playlist.actor.fid,
"published": playlist.creation_date.isoformat(),
"audience": playlist.privacy_level,
"library": playlist.library.fid,
}
if playlist.modification_date:
payload["updated"] = playlist.modification_date.isoformat()
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
return payload
def create(self, validated_data):
actor = utils.retrieve_ap_object(
validated_data["attributedTo"],
actor=self.context.get("fetch_actor"),
queryset=models.Actor,
serializer_class=ActorSerializer,
)
ap_to_fw_data = {
"actor": actor,
"name": validated_data["name"],
"creation_date": validated_data["published"],
"privacy_level": validated_data["audience"],
}
playlist, created = playlists_models.Playlist.objects.update_or_create(
defaults=ap_to_fw_data,
**{
"fid": validated_data["id"],
"uuid": validated_data.get(
"uuid", validated_data["id"].rstrip("/").split("/")[-1]
),
},
)
if created or ("library" in validated_data != playlist.library.fid):
library = utils.retrieve_ap_object(
validated_data["library"],
actor=self.context.get("fetch_actor"),
queryset=music_models.Library,
serializer_class=LibrarySerializer,
)
playlist.library = library
playlist.save()
return playlist
def validate(self, data):
validated_data = super().validate(data)
if validated_data["audience"] in [
"https://www.w3.org/ns/activitystreams#Public",
"everyone",
]:
validated_data["audience"] = "everyone"
return validated_data
def update(self, instance, validated_data):
return self.create(validated_data)
class PlaylistCollectionSerializer(PaginatedCollectionSerializer):
"""
Used for the federation view.
"""
type = serializers.ChoiceField(choices=[contexts.FW.Playlist])
def to_representation(self, playlist):
conf = {
"id": playlist.fid,
"name": playlist.name,
"page_size": 100,
"actor": playlist.actor,
"items": playlist.playlist_tracks.order_by("index").prefetch_related(
"tracks",
),
"type": "Playlist",
"library": playlist.library.fid,
"published": playlist.creation_date.isoformat(),
}
r = super().to_representation(conf)
return r
class AudioCollectionSerializer(jsonld.JsonLdSerializer):
"""
Used for the activities
"""
type = serializers.ChoiceField(
choices=[contexts.AS.Collection, contexts.FW.AudioCollection]
)
actor = serializers.URLField(max_length=500, required=False)
audience = serializers.ChoiceField(
choices=fields.PRIVACY_LEVEL_CHOICES,
required=True,
)
totalItems = serializers.IntegerField()
items = UploadSerializer(many=True)
class Meta:
jsonld_mapping = {
"audience": jsonld.first_id(contexts.AS.audience),
"actor": jsonld.first_id(contexts.AS.actor),
"totalItems": jsonld.first_val(contexts.AS.totalItems),
"items": jsonld.raw(contexts.AS.items),
}
def to_representation(self, audios):
conf = {
"type": "AudioCollection",
"actor": audios[0].library.actor.fid,
"audience": audios[0].library.privacy_level,
"totalItems": len(audios),
"items": UploadSerializer(audios, many=True).data,
}
if self.context.get("include_ap_context", True):
conf["@context"] = jsonld.get_default_context()
return conf
def create(self, validated_data):
for upload_data in validated_data["items"]:
UploadSerializer().create(upload_data)
return validated_data["items"]
def update(self, instance, validated_data):
return self.create(validated_data)
import datetime import datetime
import logging import logging
import pytz import sys
import cryptography.exceptions
import requests
import requests_http_message_signatures
from django import forms from django import forms
from django.utils import timezone from django.utils import timezone
from django.utils.http import parse_http_date from django.utils.http import parse_http_date
import requests
import requests_http_signature
from . import exceptions, utils from . import exceptions, utils
if sys.version_info < (3, 9):
from backports.zoneinfo import ZoneInfo
else:
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# the request Date should be between now - 30s and now + 30s # the request Date should be between now - 30s and now + 30s
...@@ -25,11 +30,14 @@ def verify_date(raw_date): ...@@ -25,11 +30,14 @@ def verify_date(raw_date):
ts = parse_http_date(raw_date) ts = parse_http_date(raw_date)
except ValueError as e: except ValueError as e:
raise forms.ValidationError(str(e)) raise forms.ValidationError(str(e))
dt = datetime.datetime.utcfromtimestamp(ts) dt = datetime.datetime.fromtimestamp(ts, datetime.timezone.utc)
dt = dt.replace(tzinfo=pytz.utc) dt = dt.replace(tzinfo=ZoneInfo("UTC"))
delta = datetime.timedelta(seconds=DATE_HEADER_VALID_FOR) delta = datetime.timedelta(seconds=DATE_HEADER_VALID_FOR)
now = timezone.now() now = timezone.now()
if dt < now - delta or dt > now + delta: if dt < now - delta or dt > now + delta:
logger.debug(
f"Request Date {raw_date} is too too far in the future or in the past"
)
raise forms.ValidationError( raise forms.ValidationError(
"Request Date is too far in the future or in the past" "Request Date is too far in the future or in the past"
) )
...@@ -38,11 +46,22 @@ def verify_date(raw_date): ...@@ -38,11 +46,22 @@ def verify_date(raw_date):
def verify(request, public_key): def verify(request, public_key):
verify_date(request.headers.get("Date")) date = request.headers.get("Date")
logger.debug(
return requests_http_signature.HTTPSignatureAuth.verify( "Verifying request with date %s and headers %s", date, str(request.headers)
request, key_resolver=lambda **kwargs: public_key, use_auth_header=False )
verify_date(date)
try:
return requests_http_message_signatures.HTTPSignatureHeaderAuth.verify(
request, key_resolver=lambda **kwargs: public_key
)
except cryptography.exceptions.InvalidSignature:
logger.warning(
"Could not verify request with date %s and headers %s",
date,
str(request.headers),
) )
raise
def verify_django(django_request, public_key): def verify_django(django_request, public_key):
...@@ -53,20 +72,23 @@ def verify_django(django_request, public_key): ...@@ -53,20 +72,23 @@ def verify_django(django_request, public_key):
headers = utils.clean_wsgi_headers(django_request.META) headers = utils.clean_wsgi_headers(django_request.META)
for h, v in list(headers.items()): for h, v in list(headers.items()):
# we include lower-cased version of the headers for compatibility # we include lower-cased version of the headers for compatibility
# with requests_http_signature # with requests_http_message_signatures
headers[h.lower()] = v headers[h.lower()] = v
try: try:
signature = headers["Signature"] signature = headers["Signature"]
except KeyError: except KeyError:
raise exceptions.MissingSignature raise exceptions.MissingSignature
url = "http://noop{}".format(django_request.path) url = f"http://noop{django_request.path}"
query = django_request.META["QUERY_STRING"] query = django_request.META["QUERY_STRING"]
if query: if query:
url += "?{}".format(query) url += f"?{query}"
signature_headers = signature.split('headers="')[1].split('",')[0] signature_headers = signature.split('headers="')[1].split('",')[0]
expected = signature_headers.split(" ") expected = signature_headers.split(" ")
logger.debug("Signature expected headers: %s", expected) logger.debug("Signature expected headers: %s", expected)
for header in expected: for header in expected:
if header == "(request-target)":
# this one represent the request body, so not an actual HTTP header
continue
try: try:
headers[header] headers[header]
except KeyError: except KeyError:
...@@ -83,8 +105,7 @@ def verify_django(django_request, public_key): ...@@ -83,8 +105,7 @@ def verify_django(django_request, public_key):
def get_auth(private_key, private_key_id): def get_auth(private_key, private_key_id):
return requests_http_signature.HTTPSignatureAuth( return requests_http_message_signatures.HTTPSignatureHeaderAuth(
use_auth_header=False,
headers=["(request-target)", "user-agent", "host", "date"], headers=["(request-target)", "user-agent", "host", "date"],
algorithm="rsa-sha256", algorithm="rsa-sha256",
key=private_key.encode("utf-8"), key=private_key.encode("utf-8"),
......
from django.conf import settings
from rest_framework import serializers
from funkwhale_api.common import middleware, preferences, utils
from funkwhale_api.federation import utils as federation_utils
from . import models
def actor_detail_username(request, username, redirect_to_ap):
validator = federation_utils.get_actor_data_from_username
try:
username_data = validator(username)
except serializers.ValidationError:
return []
queryset = (
models.Actor.objects.filter(
preferred_username__iexact=username_data["username"]
)
.local()
.select_related("attachment_icon")
)
try:
obj = queryset.get()
except models.Actor.DoesNotExist:
return []
if redirect_to_ap:
raise middleware.ApiRedirect(obj.fid)
obj_url = utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("actor_detail", kwargs={"username": obj.preferred_username}),
)
metas = [
{"tag": "meta", "property": "og:url", "content": obj_url},
{"tag": "meta", "property": "og:title", "content": obj.display_name},
{"tag": "meta", "property": "og:type", "content": "profile"},
]
if obj.attachment_icon:
metas.append(
{
"tag": "meta",
"property": "og:image",
"content": obj.attachment_icon.download_url_medium_square_crop,
}
)
if preferences.get("federation__enabled"):
metas.append(
{
"tag": "link",
"rel": "alternate",
"type": "application/activity+json",
"href": obj.fid,
}
)
return metas
import datetime import datetime
import json
import logging import logging
import os import os
import requests from urllib.parse import urlparse
import requests
from django.conf import settings from django.conf import settings
from django.db.models import Q, F from django.core.cache import cache
from django.db import transaction
from django.db.models import F, Q
from django.db.models.deletion import Collector
from django.utils import timezone from django.utils import timezone
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
from requests.exceptions import RequestException from requests.exceptions import RequestException
from funkwhale_api.common import preferences from funkwhale_api.audio import models as audio_models
from funkwhale_api.common import session from funkwhale_api.common import models as common_models
from funkwhale_api.common import preferences, session
from funkwhale_api.common import utils as common_utils from funkwhale_api.common import utils as common_utils
from funkwhale_api.moderation import mrf
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.playlists import models as playlists_models
from funkwhale_api.taskapp import celery from funkwhale_api.taskapp import celery
from . import keys from . import (
from . import models, signing activity,
from . import serializers actors,
from . import routes exceptions,
from . import utils jsonld,
keys,
models,
routes,
serializers,
signing,
utils,
webfinger,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -40,6 +56,7 @@ def clean_music_cache(): ...@@ -40,6 +56,7 @@ def clean_music_cache():
) )
.local(False) .local(False)
.exclude(audio_file="") .exclude(audio_file="")
.filter(Q(source__startswith="http://") | Q(source__startswith="https://"))
.only("audio_file", "id") .only("audio_file", "id")
.order_by("id") .order_by("id")
) )
...@@ -84,7 +101,7 @@ def dispatch_inbox(activity, call_handlers=True): ...@@ -84,7 +101,7 @@ def dispatch_inbox(activity, call_handlers=True):
context={ context={
"activity": activity, "activity": activity,
"actor": activity.actor, "actor": activity.actor,
"inbox_items": activity.inbox_items.filter(is_read=False), "inbox_items": activity.inbox_items.filter(is_read=False).order_by("id"),
}, },
call_handlers=call_handlers, call_handlers=call_handlers,
) )
...@@ -99,7 +116,8 @@ def dispatch_outbox(activity): ...@@ -99,7 +116,8 @@ def dispatch_outbox(activity):
inbox_items = activity.inbox_items.filter(is_read=False).select_related() inbox_items = activity.inbox_items.filter(is_read=False).select_related()
if inbox_items.exists(): if inbox_items.exists():
dispatch_inbox.delay(activity_id=activity.pk, call_handlers=False) call_handlers = activity.type in ["Follow"]
dispatch_inbox.delay(activity_id=activity.pk, call_handlers=call_handlers)
if not preferences.get("federation__enabled"): if not preferences.get("federation__enabled"):
# federation is disabled, we only deliver to local recipients # federation is disabled, we only deliver to local recipients
...@@ -124,11 +142,23 @@ def dispatch_outbox(activity): ...@@ -124,11 +142,23 @@ def dispatch_outbox(activity):
"delivery", "delivery",
) )
def deliver_to_remote(delivery): def deliver_to_remote(delivery):
if not preferences.get("federation__enabled"): if not preferences.get("federation__enabled"):
# federation is disabled, we only deliver to local recipients # federation is disabled, we only deliver to local recipients
return return
# we check the domain is still reachable before attempting delivery
if (
models.Domain.objects.get(name=urlparse(delivery.inbox_url).netloc).reachable
is False
):
delivery.last_attempt_date = timezone.now()
delivery.attempts = F("attempts") + 1
delivery.save(update_fields=["last_attempt_date", "attempts"])
logger.info(
f"Skipping delivery to {delivery.inbox_url} as its domain is unreachable",
)
return
actor = delivery.activity.actor actor = delivery.activity.actor
logger.info("Preparing activity delivery to %s", delivery.inbox_url) logger.info("Preparing activity delivery to %s", delivery.inbox_url)
auth = signing.get_auth(actor.private_key, actor.private_key_id) auth = signing.get_auth(actor.private_key, actor.private_key_id)
...@@ -137,8 +167,6 @@ def deliver_to_remote(delivery): ...@@ -137,8 +167,6 @@ def deliver_to_remote(delivery):
auth=auth, auth=auth,
json=delivery.activity.payload, json=delivery.activity.payload,
url=delivery.inbox_url, url=delivery.inbox_url,
timeout=5,
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
headers={"Content-Type": "application/activity+json"}, headers={"Content-Type": "application/activity+json"},
) )
logger.debug("Remote answered with %s", response.status_code) logger.debug("Remote answered with %s", response.status_code)
...@@ -157,10 +185,8 @@ def deliver_to_remote(delivery): ...@@ -157,10 +185,8 @@ def deliver_to_remote(delivery):
def fetch_nodeinfo(domain_name): def fetch_nodeinfo(domain_name):
s = session.get_session() s = session.get_session()
wellknown_url = "https://{}/.well-known/nodeinfo".format(domain_name) wellknown_url = f"https://{domain_name}/.well-known/nodeinfo"
response = s.get( response = s.get(url=wellknown_url)
url=wellknown_url, timeout=5, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL
)
response.raise_for_status() response.raise_for_status()
serializer = serializers.NodeInfoSerializer(data=response.json()) serializer = serializers.NodeInfoSerializer(data=response.json())
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
...@@ -170,9 +196,7 @@ def fetch_nodeinfo(domain_name): ...@@ -170,9 +196,7 @@ def fetch_nodeinfo(domain_name):
nodeinfo_url = link["href"] nodeinfo_url = link["href"]
break break
response = s.get( response = s.get(url=nodeinfo_url)
url=nodeinfo_url, timeout=5, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL
)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
...@@ -185,7 +209,11 @@ def update_domain_nodeinfo(domain): ...@@ -185,7 +209,11 @@ def update_domain_nodeinfo(domain):
now = timezone.now() now = timezone.now()
try: try:
nodeinfo = {"status": "ok", "payload": fetch_nodeinfo(domain.name)} nodeinfo = {"status": "ok", "payload": fetch_nodeinfo(domain.name)}
except (requests.RequestException, serializers.serializers.ValidationError) as e: except (
requests.RequestException,
serializers.serializers.ValidationError,
ValueError,
) as e:
nodeinfo = {"status": "error", "error": str(e)} nodeinfo = {"status": "error", "error": str(e)}
service_actor_id = common_utils.recursive_getattr( service_actor_id = common_utils.recursive_getattr(
...@@ -195,13 +223,18 @@ def update_domain_nodeinfo(domain): ...@@ -195,13 +223,18 @@ def update_domain_nodeinfo(domain):
domain.service_actor = ( domain.service_actor = (
utils.retrieve_ap_object( utils.retrieve_ap_object(
service_actor_id, service_actor_id,
actor=None,
queryset=models.Actor, queryset=models.Actor,
serializer_class=serializers.ActorSerializer, serializer_class=serializers.ActorSerializer,
) )
if service_actor_id if service_actor_id
else None else None
) )
except (serializers.serializers.ValidationError, RequestException) as e: except (
serializers.serializers.ValidationError,
RequestException,
exceptions.BlockedActorOrDomain,
) as e:
logger.warning( logger.warning(
"Cannot fetch system actor for domain %s: %s", domain.name, str(e) "Cannot fetch system actor for domain %s: %s", domain.name, str(e)
) )
...@@ -210,6 +243,24 @@ def update_domain_nodeinfo(domain): ...@@ -210,6 +243,24 @@ def update_domain_nodeinfo(domain):
domain.save(update_fields=["nodeinfo", "nodeinfo_fetch_date", "service_actor"]) domain.save(update_fields=["nodeinfo", "nodeinfo_fetch_date", "service_actor"])
@celery.app.task(name="federation.refresh_nodeinfo_known_nodes")
def refresh_nodeinfo_known_nodes():
"""
Trigger a node info refresh on all nodes that weren't refreshed since
settings.NODEINFO_REFRESH_DELAY
"""
limit = timezone.now() - datetime.timedelta(seconds=settings.NODEINFO_REFRESH_DELAY)
candidates = (
models.Domain.objects.external()
.exclude(nodeinfo_fetch_date__gte=limit)
.filter(nodeinfo__software__name="Funkwhale")
)
names = candidates.values_list("name", flat=True)
logger.info("Launching periodic nodeinfo refresh on %s domains", len(names))
for domain_name in names:
update_domain_nodeinfo.delay(domain_name=domain_name)
def delete_qs(qs): def delete_qs(qs):
label = qs.model._meta.label label = qs.model._meta.label
result = qs.delete() result = qs.delete()
...@@ -232,8 +283,11 @@ def handle_purge_actors(ids, only=[]): ...@@ -232,8 +283,11 @@ def handle_purge_actors(ids, only=[]):
# purge audio content # purge audio content
if not only or "media" in only: if not only or "media" in only:
delete_qs(common_models.Attachment.objects.filter(actor__in=ids))
delete_qs(models.LibraryFollow.objects.filter(actor_id__in=ids)) delete_qs(models.LibraryFollow.objects.filter(actor_id__in=ids))
delete_qs(models.Follow.objects.filter(target_id__in=ids)) delete_qs(models.Follow.objects.filter(target_id__in=ids))
delete_qs(audio_models.Channel.objects.filter(attributed_to__in=ids))
delete_qs(audio_models.Channel.objects.filter(actor__in=ids))
delete_qs(music_models.Upload.objects.filter(library__actor_id__in=ids)) delete_qs(music_models.Upload.objects.filter(library__actor_id__in=ids))
delete_qs(music_models.Library.objects.filter(actor_id__in=ids)) delete_qs(music_models.Library.objects.filter(actor_id__in=ids))
...@@ -260,3 +314,402 @@ def rotate_actor_key(actor): ...@@ -260,3 +314,402 @@ def rotate_actor_key(actor):
actor.private_key = pair[0].decode() actor.private_key = pair[0].decode()
actor.public_key = pair[1].decode() actor.public_key = pair[1].decode()
actor.save(update_fields=["private_key", "public_key"]) actor.save(update_fields=["private_key", "public_key"])
@celery.app.task(name="federation.fetch")
@transaction.atomic
@celery.require_instance(
models.Fetch.objects.filter(status="pending").select_related("actor"),
"fetch_obj",
"fetch_id",
)
def fetch(fetch_obj):
def error(code, **kwargs):
fetch_obj.status = "errored"
fetch_obj.fetch_date = timezone.now()
fetch_obj.detail = {"error_code": code}
fetch_obj.detail.update(kwargs)
fetch_obj.save(update_fields=["fetch_date", "status", "detail"])
url = fetch_obj.url
mrf_check_url = url
if not mrf_check_url.startswith("webfinger://"):
payload, updated = mrf.inbox.apply({"id": mrf_check_url})
if not payload:
return error("blocked", message="Blocked by MRF")
actor = fetch_obj.actor
if settings.FEDERATION_AUTHENTIFY_FETCHES:
auth = signing.get_auth(actor.private_key, actor.private_key_id)
else:
auth = None
try:
if url.startswith("webfinger://"):
# we first grab the corresponding webfinger representation
# to get the ActivityPub actor ID
webfinger_data = webfinger.get_resource(
"acct:" + url.replace("webfinger://", "")
)
url = webfinger.get_ap_url(webfinger_data["links"])
if not url:
return error("webfinger", message="Invalid or missing webfinger data")
payload, updated = mrf.inbox.apply({"id": url})
if not payload:
return error("blocked", message="Blocked by MRF")
response = session.get_session().get(
auth=auth,
url=url,
headers={"Accept": "application/activity+json"},
)
logger.debug("Remote answered with %s: %s", response.status_code, response.text)
response.raise_for_status()
except requests.exceptions.HTTPError as e:
return error(
"http",
status_code=e.response.status_code if e.response else None,
message=e.response.text,
)
except requests.exceptions.Timeout:
return error("timeout")
except requests.exceptions.ConnectionError as e:
return error("connection", message=str(e))
except requests.RequestException as e:
return error("request", message=str(e))
except Exception as e:
return error("unhandled", message=str(e))
try:
payload = response.json()
except json.decoder.JSONDecodeError:
# we attempt to extract a <link rel=alternate> that points
# to an activity pub resource, if possible, and retry with this URL
alternate_url = utils.find_alternate(response.text)
if alternate_url:
fetch_obj.url = alternate_url
fetch_obj.save(update_fields=["url"])
return fetch(fetch_id=fetch_obj.pk)
return error("invalid_json")
payload, updated = mrf.inbox.apply(payload)
if not payload:
return error("blocked", message="Blocked by MRF")
try:
doc = jsonld.expand(payload)
except ValueError:
return error("invalid_jsonld")
try:
type = doc.get("@type", [])[0]
except IndexError:
return error("missing_jsonld_type")
try:
serializer_classes = fetch_obj.serializers[type]
model = serializer_classes[0].Meta.model
except (KeyError, AttributeError):
fetch_obj.status = "skipped"
fetch_obj.fetch_date = timezone.now()
fetch_obj.detail = {"reason": "unhandled_type", "type": type}
return fetch_obj.save(update_fields=["fetch_date", "status", "detail"])
try:
id = doc.get("@id")
except IndexError:
existing = None
else:
existing = model.objects.filter(fid=id).first()
serializer = None
for serializer_class in serializer_classes:
serializer = serializer_class(
existing, data=payload, context={"fetch_actor": actor}
)
if not serializer.is_valid():
continue
else:
break
if serializer.errors:
return error("validation", validation_errors=serializer.errors)
try:
obj = serializer.save()
except Exception as e:
error("save", message=str(e))
raise
# special case for channels
# when obj is an actor, we check if the actor has a channel associated with it
# if it is the case, we consider the fetch obj to be a channel instead
# and also trigger a fetch on the channel outbox
if isinstance(obj, models.Actor) and obj.get_channel():
obj = obj.get_channel()
if obj.actor.outbox_url:
try:
# first page fetch is synchronous, so that at least some data is available
# in the UI after subscription
result = fetch_collection(
obj.actor.outbox_url,
channel_id=obj.pk,
max_pages=1,
)
except Exception:
logger.exception(
"Error while fetching actor outbox: %s", obj.actor.outbox_url
)
else:
if result.get("next_page"):
# additional pages are fetched in the background
result = fetch_collection.delay(
result["next_page"],
channel_id=obj.pk,
max_pages=settings.FEDERATION_COLLECTION_MAX_PAGES - 1,
is_page=True,
)
fetch_obj.object = obj
fetch_obj.status = "finished"
fetch_obj.fetch_date = timezone.now()
return fetch_obj.save(
update_fields=["fetch_date", "status", "object_id", "object_content_type"]
)
class PreserveSomeDataCollector(Collector):
"""
We need to delete everything related to an actor. Well… Almost everything.
But definitely not the Delete Activity we send to announce the actor is deleted.
"""
def __init__(self, *args, **kwargs):
self.creation_date = timezone.now()
super().__init__(*args, **kwargs)
def related_objects(self, related, *args, **kwargs):
qs = super().related_objects(related, *args, **kwargs)
# We can only exclude the actions if these fields are available, most likely its a
# model.Activity than
if hasattr(related, "type") and hasattr(related, "creation_date"):
# exclude the delete activity can be broadcasted properly
qs = qs.exclude(type="Delete", creation_date__gte=self.creation_date)
return qs
@celery.app.task(name="federation.remove_actor")
@transaction.atomic
@celery.require_instance(
models.Actor.objects.all(),
"actor",
)
def remove_actor(actor):
# Then we broadcast the info over federation. We do this *before* deleting objects
# associated with the actor, otherwise follows are removed and we don't know where
# to broadcast
logger.info("Broadcasting deletion to federation…")
collector = PreserveSomeDataCollector(using="default")
routes.outbox.dispatch(
{"type": "Delete", "object": {"type": actor.type}}, context={"actor": actor}
)
# then we delete any object associated with the actor object, but *not* the actor
# itself. We keep it for auditability and sending the Delete ActivityPub message
logger.info(
"Prepare deletion of objects associated with account %s…",
actor.preferred_username,
)
collector.collect([actor])
for model, instances in collector.data.items():
if issubclass(model, actor.__class__):
# we skip deletion of the actor itself
continue
to_delete = model.objects.filter(pk__in=[instance.pk for instance in instances])
logger.info(
"Deleting %s objects associated with account %s…",
len(instances),
actor.preferred_username,
)
to_delete.delete()
# Finally, we update the actor itself and mark it as removed
logger.info("Marking actor as Tombsone…")
actor.type = "Tombstone"
actor.name = None
actor.summary = None
actor.save(update_fields=["type", "name", "summary"])
COLLECTION_ACTIVITY_SERIALIZERS = [
(
{"type": "Create", "object.type": "Audio"},
serializers.ChannelCreateUploadSerializer,
)
]
def match_serializer(payload, conf):
return [
serializer_class
for route, serializer_class in conf
if activity.match_route(route, payload)
]
@celery.app.task(name="federation.fetch_collection")
@celery.require_instance(
audio_models.Channel.objects.all(),
"channel",
allow_null=True,
)
def fetch_collection(url, max_pages, channel, is_page=False):
actor = actors.get_service_actor()
results = {
"items": [],
"skipped": 0,
"errored": 0,
"seen": 0,
"total": 0,
}
if is_page:
# starting immediately from a page, no need to fetch the wrapping collection
logger.debug("Fetch collection page immediately at %s", url)
results["next_page"] = url
else:
logger.debug("Fetching collection object at %s", url)
collection = utils.retrieve_ap_object(
url,
actor=actor,
serializer_class=serializers.PaginatedCollectionSerializer,
)
results["next_page"] = collection["first"]
results["total"] = collection.get("totalItems")
seen_pages = 0
context = {}
if channel:
context["channel"] = channel
for i in range(max_pages):
page_url = results["next_page"]
logger.debug("Handling page %s on max %s, at %s", i + 1, max_pages, page_url)
page = utils.retrieve_ap_object(
page_url,
actor=actor,
serializer_class=None,
)
try:
items = page["orderedItems"]
except KeyError:
try:
items = page["items"]
except KeyError:
logger.error("Invalid collection page at %s", page_url)
break
for item in items:
results["seen"] += 1
matching_serializer = match_serializer(
item, COLLECTION_ACTIVITY_SERIALIZERS
)
if not matching_serializer:
results["skipped"] += 1
logger.debug("Skipping unhandled activity %s", item.get("type"))
continue
s = matching_serializer[0](data=item, context=context)
if not s.is_valid():
logger.warn("Skipping invalid activity: %s", s.errors)
results["errored"] += 1
continue
results["items"].append(s.save())
seen_pages += 1
results["next_page"] = page.get("next", None) or None
if not results["next_page"]:
logger.debug("No more pages to fetch")
break
logger.info(
"Finished fetch of collection pages at %s. Results:\n"
" Total in collection: %s\n"
" Seen: %s\n"
" Handled: %s\n"
" Skipped: %s\n"
" Errored: %s",
url,
results.get("total"),
results["seen"],
len(results["items"]),
results["skipped"],
results["errored"],
)
return results
@celery.app.task(name="federation.check_all_remote_instance_availability")
def check_all_remote_instance_availability():
base_interval = 3600
factor = 1.15
for domain in models.Domain.objects.all():
if domain.name == settings.FUNKWHALE_HOSTNAME:
continue
attempt = domain.reachable_retries or 0
last_success = domain.last_successful_contact or domain.creation_date
delay_seconds = base_interval * (factor**attempt)
delay = datetime.timedelta(seconds=delay_seconds)
next_check_due = last_success + delay
now = timezone.now()
if domain.reachable is False and now < next_check_due:
logger.info(
f"[{domain.name}] Skipping check. Last successful: {last_success}, "
f"attempt #{attempt}, next check due in {delay} at {next_check_due}"
)
continue
check_single_remote_instance_availability(domain)
@celery.app.task(name="federation.check_single_remote_instance_availability")
def check_single_remote_instance_availability(domain):
try:
nodeinfo = fetch_nodeinfo(domain.name)
except Exception as e:
logger.info(
f"Domain {domain.name} could not be reached because of the following error : {e}. \
Setting domain as unreachable."
)
domain.reachable = False
domain.reachable_retries += 1
domain.save()
return domain.reachable
if "version" in nodeinfo.keys():
domain.reachable = True
domain.last_successful_contact = timezone.now()
domain.reachable_retries = 0
domain.save()
return domain.reachable
else:
logger.info(
f"Domain {domain.name} is not reachable at the moment. Setting domain as unreachable."
)
domain.reachable = False
domain.reachable_retries += 1
domain.save()
return domain.reachable
@celery.app.task(name="federation.trigger_playlist_ap_update")
def trigger_playlist_ap_update(playlist):
for playlist_uuid in cache.get("playlists_for_ap_update"):
routes.outbox.dispatch(
{"type": "Update", "object": {"type": "Playlist"}},
context={
"playlist": playlists_models.Playlist.objects.get(uuid=playlist_uuid)
},
)
from django.conf.urls import include, url from django.conf.urls import include
from django.urls import re_path
from rest_framework import routers from rest_framework import routers
from . import views from . import views
router = routers.SimpleRouter(trailing_slash=False) router = routers.SimpleRouter(trailing_slash=False)
music_router = routers.SimpleRouter(trailing_slash=False) music_router = routers.SimpleRouter(trailing_slash=False)
index_router = routers.SimpleRouter(trailing_slash=False)
router.register(r"federation/shared", views.SharedViewSet, "shared") router.register(r"federation/shared", views.SharedViewSet, "shared")
router.register(r"federation/actors", views.ActorViewSet, "actors") router.register(r"federation/actors", views.ActorViewSet, "actors")
router.register(r"federation/edits", views.EditViewSet, "edits") router.register(r"federation/edits", views.EditViewSet, "edits")
router.register(r"federation/reports", views.ReportViewSet, "reports")
router.register(r".well-known", views.WellKnownViewSet, "well-known") router.register(r".well-known", views.WellKnownViewSet, "well-known")
music_router.register(r"libraries", views.MusicLibraryViewSet, "libraries") music_router.register(r"libraries", views.MusicLibraryViewSet, "libraries")
music_router.register(r"uploads", views.MusicUploadViewSet, "uploads") music_router.register(r"uploads", views.MusicUploadViewSet, "uploads")
music_router.register(r"artists", views.MusicArtistViewSet, "artists") music_router.register(r"artists", views.MusicArtistViewSet, "artists")
music_router.register(r"artistcredit", views.MusicArtistCreditViewSet, "artistcredit")
music_router.register(r"albums", views.MusicAlbumViewSet, "albums") music_router.register(r"albums", views.MusicAlbumViewSet, "albums")
music_router.register(r"tracks", views.MusicTrackViewSet, "tracks") music_router.register(r"tracks", views.MusicTrackViewSet, "tracks")
music_router.register(r"likes", views.TrackFavoriteViewSet, "likes")
music_router.register(r"listenings", views.ListeningViewSet, "listenings")
music_router.register(r"playlists", views.PlaylistViewSet, "playlists")
music_router.register(r"playlists", views.PlaylistTrackViewSet, "playlist-tracks")
index_router.register(r"index", views.IndexViewSet, "index")
urlpatterns = router.urls + [ urlpatterns = router.urls + [
url("federation/music/", include((music_router.urls, "music"), namespace="music")) re_path(
"federation/music/", include((music_router.urls, "music"), namespace="music")
),
re_path("federation/", include((index_router.urls, "index"), namespace="index")),
] ]
import unicodedata import html.parser
import re import re
import unicodedata
import urllib.parse
from django.apps import apps
from django.conf import settings from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import CharField, Q, Value
from funkwhale_api.common import session from funkwhale_api.common import session
from funkwhale_api.moderation import models as moderation_models from funkwhale_api.moderation import mrf
from . import exceptions from . import exceptions, signing
from . import signing
def full_url(path): def full_url(path):
...@@ -61,12 +66,16 @@ def slugify_username(username): ...@@ -61,12 +66,16 @@ def slugify_username(username):
def retrieve_ap_object( def retrieve_ap_object(
fid, actor=None, serializer_class=None, queryset=None, apply_instance_policies=True fid,
actor,
serializer_class=None,
queryset=None,
apply_instance_policies=True,
): ):
from . import activity # we have a duplicate check here because it's less expensive to do those checks
# twice than to trigger a HTTP request
policies = moderation_models.InstancePolicy.objects.active().filter(block_all=True) payload, updated = mrf.inbox.apply({"id": fid})
if apply_instance_policies and policies.matching_url(fid): if not payload:
raise exceptions.BlockedActorOrDomain() raise exceptions.BlockedActorOrDomain()
if queryset: if queryset:
try: try:
...@@ -83,8 +92,6 @@ def retrieve_ap_object( ...@@ -83,8 +92,6 @@ def retrieve_ap_object(
response = session.get_session().get( response = session.get_session().get(
fid, fid,
auth=auth, auth=auth,
timeout=5,
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
headers={ headers={
"Accept": "application/activity+json", "Accept": "application/activity+json",
"Content-Type": "application/activity+json", "Content-Type": "application/activity+json",
...@@ -93,17 +100,254 @@ def retrieve_ap_object( ...@@ -93,17 +100,254 @@ def retrieve_ap_object(
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
# we match against moderation policies here again, because the FID of the returned # we match against mrf here again, because new data may yield different
# object may not be the same as the URL used to access it # results
try: data, updated = mrf.inbox.apply(data)
id = data["id"] if not data:
except KeyError:
pass
else:
if apply_instance_policies and activity.should_reject(fid=id, payload=data):
raise exceptions.BlockedActorOrDomain() raise exceptions.BlockedActorOrDomain()
if not serializer_class: if not serializer_class:
return data return data
serializer = serializer_class(data=data) serializer = serializer_class(data=data, context={"fetch_actor": actor})
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
try:
return serializer.save() return serializer.save()
except NotImplementedError:
return serializer.validated_data
def get_domain_query_from_url(domain, url_field="fid"):
"""
Given a domain name and a field, will return a Q() object
to match objects that have this domain in the given field.
"""
query = Q(**{f"{url_field}__startswith": f"http://{domain}/"})
query = query | Q(**{f"{url_field}__startswith": f"https://{domain}/"})
return query
def local_qs(queryset, url_field="fid", include=True):
query = get_domain_query_from_url(
domain=settings.FEDERATION_HOSTNAME, url_field=url_field
)
if not include:
query = ~query
return queryset.filter(query)
def is_local(url) -> bool:
if not url:
return True
d = settings.FEDERATION_HOSTNAME
return url.startswith(f"http://{d}/") or url.startswith(f"https://{d}/")
def get_actor_data_from_username(username):
parts = username.split("@")
return {
"username": parts[0],
"domain": parts[1] if len(parts) > 1 else settings.FEDERATION_HOSTNAME,
}
def get_actor_from_username_data_query(field, data):
if not data:
return Q(**{field: None})
if field:
return Q(
**{
f"{field}__preferred_username__iexact": data["username"],
f"{field}__domain__name__iexact": data["domain"],
}
)
else:
return Q(
**{
"preferred_username__iexact": data["username"],
"domain__name__iexact": data["domain"],
}
)
class StopParsing(Exception):
pass
class AlternateLinkParser(html.parser.HTMLParser):
def __init__(self, *args, **kwargs):
self.result = None
super().__init__(*args, **kwargs)
def handle_starttag(self, tag, attrs):
if tag != "link":
return
attrs_dict = dict(attrs)
if attrs_dict.get("rel") == "alternate" and attrs_dict.get(
"type", "application/activity+json"
):
self.result = attrs_dict.get("href")
raise StopParsing()
def handle_endtag(self, tag):
if tag == "head":
raise StopParsing()
def find_alternate(response_text):
if not response_text:
return
parser = AlternateLinkParser()
try:
parser.feed(response_text)
except StopParsing:
return parser.result
def should_redirect_ap_to_html(accept_header, default=True):
if not accept_header:
return False
redirect_headers = [
"text/html",
]
no_redirect_headers = [
"application/json",
"application/activity+json",
"application/ld+json",
]
parsed_header = [ct.lower().strip() for ct in accept_header.split(",")]
for ct in parsed_header:
if ct in redirect_headers:
return True
if ct in no_redirect_headers:
return False
return default
FID_MODEL_LABELS = [
"music.Artist",
"music.Album",
"music.Track",
"music.Library",
"music.Upload",
"federation.Actor",
]
def get_object_by_fid(fid, local=None):
if local:
parsed = urllib.parse.urlparse(fid)
if parsed.netloc != settings.FEDERATION_HOSTNAME:
raise ObjectDoesNotExist()
models = [apps.get_model(*l.split(".")) for l in FID_MODEL_LABELS]
def get_qs(model):
return (
model.objects.all()
.filter(fid=fid)
.annotate(__type=Value(model._meta.label, output_field=CharField()))
.values("fid", "__type")
)
qs = get_qs(models[0])
for m in models[1:]:
qs = qs.union(get_qs(m))
result = qs.order_by("fid").first()
if not result:
raise ObjectDoesNotExist()
model = apps.get_model(*result["__type"].split("."))
instance = model.objects.get(fid=fid)
if model._meta.label == "federation.Actor":
channel = instance.get_channel()
if channel:
return channel
return instance
def can_manage(obj_owner, actor):
if not obj_owner:
return False
if not actor:
return False
if obj_owner == actor:
return True
if obj_owner.domain.service_actor == actor:
return True
return False
def update_actor_privacy(actor, privacy_level):
actor.track_favorites.update(privacy_level=privacy_level)
actor.listenings.update(privacy_level=privacy_level)
# to do : trigger federation privacy_level downgrade #2336
class BuiltInLibException(Exception):
pass
def get_or_create_builtin_actor_library(actor, privacy_level):
from funkwhale_api.music import models as music_models
from . import actors
lib_qs = music_models.Library.objects.filter(
actor=actor,
playlist__isnull=True,
privacy_level=privacy_level,
name=privacy_level,
)
if lib_qs.exists() and lib_qs.count() == 1:
return lib_qs.first()
service_actor = actors.get_service_actor()
auth = signing.get_auth(service_actor.private_key, service_actor.private_key_id)
response = session.get_session().get(
f"https://{actor.domain}/api/v2/federation/music/libraries",
auth=auth,
params={
"actor": actor.preferred_username,
"privacy_level": privacy_level,
"name": privacy_level,
},
headers={
"Accept": "application/activity+json",
"Content-Type": "application/activity+json",
},
)
response.raise_for_status()
data = response.json()
if len(data["results"]) == 0:
raise BuiltInLibException(
f"Could not find built-in lib {privacy_level} for actor {actor}"
)
elif not len(data["results"]) == 1:
raise BuiltInLibException(
f"Too many built-in lib {privacy_level} for actor {actor}"
)
else:
lib, created = music_models.Library.objects.get_or_create(
actor=actor,
playlist__isnull=True,
privacy_level=privacy_level,
name=privacy_level,
uuid=data["results"][0]["id"].split("/")[-1],
fid=data["results"][0]["id"],
)
lib.schedule_scan(actor=service_actor)
return lib
from django import forms from django import forms
from django.conf import settings
from django.core import paginator from django.core import paginator
from django.db.models import Prefetch
from django.http import HttpResponse from django.http import HttpResponse
from django.urls import reverse from django.urls import reverse
from rest_framework import exceptions, mixins, response, viewsets from rest_framework import exceptions, mixins, permissions, response, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from funkwhale_api.common import permissions as common_permissions
from funkwhale_api.common import preferences from funkwhale_api.common import preferences
from funkwhale_api.common import utils as common_utils
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.history import models as history_models
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.music import filters as music_filters
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.music import utils as music_utils from funkwhale_api.music import utils as music_utils
from funkwhale_api.playlists import models as playlists_models
from . import (
activity,
actors,
authentication,
models,
renderers,
serializers,
utils,
webfinger,
)
from . import activity, authentication, models, renderers, serializers, utils, webfinger def redirect_to_html(public_url):
response = HttpResponse(status=302)
response["Location"] = common_utils.join_url(settings.FUNKWHALE_URL, public_url)
return response
class FederationMixin(object): def get_collection_response(
conf, querystring, collection_serializer, page_access_check=None
):
page = querystring.get("page")
if page is None:
data = collection_serializer.data
else:
if page_access_check and not page_access_check():
raise exceptions.AuthenticationFailed(
"You do not have access to this resource"
)
try:
page_number = int(page)
except Exception:
return response.Response({"page": ["Invalid page number"]}, status=400)
conf["page_size"] = preferences.get("federation__collection_page_size")
p = paginator.Paginator(conf["items"], conf["page_size"])
try:
page = p.page(page_number)
conf["page"] = page
serializer = serializers.CollectionPageSerializer(conf)
data = serializer.data
except paginator.EmptyPage:
return response.Response(status=404)
return response.Response(data)
class AuthenticatedIfAllowListEnabled(permissions.BasePermission):
def has_permission(self, request, view):
allow_list_enabled = preferences.get("moderation__allow_list_enabled")
if not allow_list_enabled:
return True
return bool(request.actor)
class FederationMixin:
permission_classes = [AuthenticatedIfAllowListEnabled]
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
if not preferences.get("federation__enabled"): if not preferences.get("federation__enabled"):
return HttpResponse(status=405) return HttpResponse(status=405)
...@@ -20,11 +83,14 @@ class FederationMixin(object): ...@@ -20,11 +83,14 @@ class FederationMixin(object):
class SharedViewSet(FederationMixin, viewsets.GenericViewSet): class SharedViewSet(FederationMixin, viewsets.GenericViewSet):
permission_classes = []
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers() renderer_classes = renderers.get_ap_renderers()
@action(methods=["post"], detail=False) @action(
methods=["post"],
detail=False,
content_negotiation_class=renderers.IgnoreClientContentNegotiation,
)
def inbox(self, request, *args, **kwargs): def inbox(self, request, *args, **kwargs):
if request.method.lower() == "post" and request.actor is None: if request.method.lower() == "post" and request.actor is None:
raise exceptions.AuthenticationFailed( raise exceptions.AuthenticationFailed(
...@@ -38,47 +104,207 @@ class SharedViewSet(FederationMixin, viewsets.GenericViewSet): ...@@ -38,47 +104,207 @@ class SharedViewSet(FederationMixin, viewsets.GenericViewSet):
class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
lookup_field = "preferred_username" lookup_field = "preferred_username"
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
permission_classes = []
renderer_classes = renderers.get_ap_renderers() renderer_classes = renderers.get_ap_renderers()
queryset = models.Actor.objects.local().select_related("user") queryset = (
models.Actor.objects.local()
.select_related("user", "channel__artist", "channel__attributed_to")
.prefetch_related("channel__artist__tagged_items__tag")
)
serializer_class = serializers.ActorSerializer serializer_class = serializers.ActorSerializer
@action(methods=["get", "post"], detail=True) def get_queryset(self):
queryset = super().get_queryset()
return queryset.exclude(channel__attributed_to=actors.get_service_actor())
def get_permissions(self):
# cf #1999 it must be possible to fetch actors without being authenticated
# otherwise we end up in a loop
if self.action == "retrieve":
return []
return super().get_permissions()
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
if instance.get_channel():
return redirect_to_html(instance.channel.get_absolute_url())
return redirect_to_html(instance.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
@action(
methods=["get", "post"],
detail=True,
content_negotiation_class=renderers.IgnoreClientContentNegotiation,
)
def inbox(self, request, *args, **kwargs): def inbox(self, request, *args, **kwargs):
inbox_actor = self.get_object()
if request.method.lower() == "post" and request.actor is None: if request.method.lower() == "post" and request.actor is None:
raise exceptions.AuthenticationFailed( raise exceptions.AuthenticationFailed(
"You need a valid signature to send an activity" "You need a valid signature to send an activity"
) )
if request.method.lower() == "post": if request.method.lower() == "post":
activity.receive(activity=request.data, on_behalf_of=request.actor) activity.receive(
activity=request.data,
on_behalf_of=request.actor,
inbox_actor=inbox_actor,
)
return response.Response({}, status=200) return response.Response({}, status=200)
@action(methods=["get", "post"], detail=True) @action(methods=["get", "post"], detail=True)
def outbox(self, request, *args, **kwargs): def outbox(self, request, *args, **kwargs):
actor = self.get_object()
channel = actor.get_channel()
if channel:
return self.get_channel_outbox_response(request, channel)
return response.Response({}, status=200) return response.Response({}, status=200)
@action(methods=["get"], detail=True) def get_channel_outbox_response(self, request, channel):
conf = {
"id": channel.actor.outbox_url,
"actor": channel.actor,
"items": channel.library.uploads.for_federation()
.order_by("-creation_date")
.prefetch_related(
"library__channel__actor", "track__artist_credit__artist"
),
"item_serializer": serializers.ChannelCreateUploadSerializer,
}
return get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.ChannelOutboxSerializer(channel),
)
@action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def followers(self, request, *args, **kwargs): def followers(self, request, *args, **kwargs):
self.get_object() actor = self.get_object()
# XXX to implement followers = list(actor.get_approved_followers())
return response.Response({}) conf = {
"id": federation_utils.full_url(
reverse(
"federation:actors-followers",
kwargs={"preferred_username": actor.preferred_username},
)
),
"items": followers,
"item_serializer": serializers.ActorSerializer,
"page_size": 100,
"actor": None,
}
response = get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response
@action(methods=["get"], detail=True) @action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def following(self, request, *args, **kwargs): def following(self, request, *args, **kwargs):
self.get_object() actor = self.get_object()
# XXX to implement followings = list(
return response.Response({}) actor.emitted_follows.filter(approved=True).values_list("target", flat=True)
)
conf = {
"id": federation_utils.full_url(
reverse(
"federation:actors-following",
kwargs={"preferred_username": actor.preferred_username},
)
),
"items": followings,
"item_serializer": serializers.ActorSerializer,
"page_size": 100,
"actor": None,
}
response = get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response
@action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def listens(self, request, *args, **kwargs):
actor = self.get_object()
listenings = history_models.Listening.objects.filter(actor=actor)
conf = {
"id": federation_utils.full_url(
reverse(
"federation:actors-listens",
kwargs={"preferred_username": actor.preferred_username},
)
),
"items": listenings,
"item_serializer": serializers.ListeningSerializer,
"page_size": 100,
"actor": None,
}
response = get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response
@action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def likes(self, request, *args, **kwargs):
actor = self.get_object()
likes = favorites_models.TrackFavorite.objects.filter(actor=actor)
conf = {
"id": federation_utils.full_url(
reverse(
"federation:actors-likes",
kwargs={"preferred_username": actor.preferred_username},
)
),
"items": likes,
"item_serializer": serializers.TrackFavoriteSerializer,
"page_size": 100,
"actor": None,
}
response = get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response
class EditViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): class EditViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
lookup_field = "uuid" lookup_field = "uuid"
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
permission_classes = []
renderer_classes = renderers.get_ap_renderers() renderer_classes = renderers.get_ap_renderers()
# queryset = common_models.Mutation.objects.local().select_related() # queryset = common_models.Mutation.objects.local().select_related()
# serializer_class = serializers.ActorSerializer # serializer_class = serializers.ActorSerializer
class ReportViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
lookup_field = "uuid"
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = moderation_models.Report.objects.none()
class WellKnownViewSet(viewsets.GenericViewSet): class WellKnownViewSet(viewsets.GenericViewSet):
authentication_classes = [] authentication_classes = []
permission_classes = [] permission_classes = []
...@@ -86,8 +312,6 @@ class WellKnownViewSet(viewsets.GenericViewSet): ...@@ -86,8 +312,6 @@ class WellKnownViewSet(viewsets.GenericViewSet):
@action(methods=["get"], detail=False) @action(methods=["get"], detail=False)
def nodeinfo(self, request, *args, **kwargs): def nodeinfo(self, request, *args, **kwargs):
if not preferences.get("instance__nodeinfo_enabled"):
return HttpResponse(status=404)
data = { data = {
"links": [ "links": [
{ {
...@@ -104,9 +328,9 @@ class WellKnownViewSet(viewsets.GenericViewSet): ...@@ -104,9 +328,9 @@ class WellKnownViewSet(viewsets.GenericViewSet):
return HttpResponse(status=405) return HttpResponse(status=405)
try: try:
resource_type, resource = webfinger.clean_resource(request.GET["resource"]) resource_type, resource = webfinger.clean_resource(request.GET["resource"])
cleaner = getattr(webfinger, "clean_{}".format(resource_type)) cleaner = getattr(webfinger, f"clean_{resource_type}")
result = cleaner(resource) result = cleaner(resource)
handler = getattr(self, "handler_{}".format(resource_type)) handler = getattr(self, f"handler_{resource_type}")
data = handler(result) data = handler(result)
except forms.ValidationError as e: except forms.ValidationError as e:
return response.Response({"errors": {"resource": e.message}}, status=400) return response.Response({"errors": {"resource": e.message}}, status=400)
...@@ -135,60 +359,114 @@ def has_library_access(request, library): ...@@ -135,60 +359,114 @@ def has_library_access(request, library):
return True return True
try: try:
actor = request.actor actor = music_utils.get_actor_from_request(request)
except AttributeError: except AttributeError:
return False return False
return library.received_follows.filter(actor=actor, approved=True).exists() if library.received_follows.filter(actor=actor, approved=True).exists():
return True
if (
library.privacy_level == "followers"
and library.actor.received_follows.filter(actor=actor, approved=True).exists()
):
return True
if library.privacy_level == "followers" and library.actor.received_follows.filter(
actor__domain__in=actor.managed_domains.all()
):
return True
def has_playlist_access(request, playlist):
if playlist.privacy_level == "everyone":
return True
if request.user.is_authenticated and request.user.is_superuser:
return True
try:
actor = music_utils.get_actor_from_request(request)
except AttributeError:
return False
if playlist.library.received_follows.filter(actor=actor, approved=True).exists():
return True
if (
playlist.privacy_level == "followers"
and playlist.actor.received_follows.filter(actor=actor, approved=True).exists()
):
return True
if (
playlist.privacy_level == "followers"
and playlist.actor.received_follows.filter(
actor__domain__in=actor.managed_domains.all()
)
):
return True
class MusicLibraryViewSet( class MusicLibraryViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet FederationMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
mixins.ListModelMixin,
): ):
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
permission_classes = []
renderer_classes = renderers.get_ap_renderers() renderer_classes = renderers.get_ap_renderers()
serializer_class = serializers.LibrarySerializer serializer_class = serializers.LibrarySerializer
queryset = music_models.Library.objects.all().select_related("actor") queryset = (
music_models.Library.objects.all()
.local()
.select_related("actor")
.filter(channel=None)
)
filterset_class = music_filters.LibraryFilter
lookup_field = "uuid" lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
lb = self.get_object() lb = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(lb.get_absolute_url())
items_qs = (
lb.uploads.for_federation()
if not lb.playlist_uploads.all()
else lb.playlist_uploads.for_federation()
)
conf = { conf = {
"id": lb.get_federation_id(), "id": lb.get_federation_id(),
"actor": lb.actor, "actor": lb.actor,
"name": lb.name, "name": lb.name,
"summary": lb.description, "items": items_qs.order_by("-creation_date").prefetch_related(
"items": lb.uploads.for_federation().order_by("-creation_date"), Prefetch(
"track",
queryset=music_models.Track.objects.select_related(
"attachment_cover",
"album__attributed_to",
"attributed_to",
"album__attachment_cover",
"description",
).prefetch_related(
"album__artist_credit__artist__attributed_to",
"artist_credit__artist__attributed_to",
"artist_credit__artist__attachment_cover",
"tagged_items__tag",
"album__tagged_items__tag",
"album__artist_credit__artist__tagged_items__tag",
"album__artist_credit__artist__attachment_cover",
"artist_credit__artist__tagged_items__tag",
"artist_credit__artist__description",
"album__description",
),
)
),
"item_serializer": serializers.UploadSerializer, "item_serializer": serializers.UploadSerializer,
"library": lb,
} }
page = request.GET.get("page") return get_collection_response(
if page is None: conf=conf,
serializer = serializers.LibrarySerializer(lb) querystring=request.GET,
data = serializer.data collection_serializer=serializers.LibrarySerializer(lb),
else: page_access_check=lambda: has_library_access(request, lb),
# if actor is requesting a specific page, we ensure library is public
# or readable by the actor
if not has_library_access(request, lb):
raise exceptions.AuthenticationFailed(
"You do not have access to this library"
) )
try:
page_number = int(page)
except Exception:
return response.Response({"page": ["Invalid page number"]}, status=400)
conf["page_size"] = preferences.get("federation__collection_page_size")
p = paginator.Paginator(conf["items"], conf["page_size"])
try:
page = p.page(page_number)
conf["page"] = page
serializer = serializers.CollectionPageSerializer(conf)
data = serializer.data
except paginator.EmptyPage:
return response.Response(status=404)
return response.Response(data)
@action(methods=["get"], detail=True) @action(methods=["get"], detail=True)
def followers(self, request, *args, **kwargs): def followers(self, request, *args, **kwargs):
...@@ -201,50 +479,325 @@ class MusicUploadViewSet( ...@@ -201,50 +479,325 @@ class MusicUploadViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
): ):
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
permission_classes = []
renderer_classes = renderers.get_ap_renderers() renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Upload.objects.local().select_related( queryset = (
"library__actor", "track__artist", "track__album__artist" music_models.Upload.objects.local()
.select_related(
"library__actor",
"track__description",
"track__album__attachment_cover",
"track__attachment_cover",
)
.prefetch_related(
"track__artist_credit__artist",
"track__album__artist_credit__artist",
"track__album__artist_credit__artist__attachment_cover",
"track__artist_credit__artist__attachment_cover",
)
) )
serializer_class = serializers.UploadSerializer serializer_class = serializers.UploadSerializer
lookup_field = "uuid" lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(instance.track.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
actor = music_utils.get_actor_from_request(self.request) actor = music_utils.get_actor_from_request(self.request)
return queryset.playable_by(actor) return queryset.playable_by(actor)
def get_serializer(self, obj):
if obj.library.get_channel():
return serializers.ChannelUploadSerializer(obj)
return super().get_serializer(obj)
@action(
methods=["get"],
detail=True,
content_negotiation_class=renderers.IgnoreClientContentNegotiation,
)
def activity(self, request, *args, **kwargs):
object = self.get_object()
serializer = serializers.ChannelCreateUploadSerializer(object)
return response.Response(serializer.data)
class MusicArtistViewSet( class MusicArtistViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
): ):
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
permission_classes = []
renderer_classes = renderers.get_ap_renderers() renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Artist.objects.local() queryset = music_models.Artist.objects.local().select_related(
"description", "attachment_cover"
)
serializer_class = serializers.ArtistSerializer serializer_class = serializers.ArtistSerializer
lookup_field = "uuid" lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(instance.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class MusicArtistCreditViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.ArtistCredit.objects.local().prefetch_related("artist")
serializer_class = serializers.ArtistCreditSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class MusicAlbumViewSet( class MusicAlbumViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
): ):
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
permission_classes = []
renderer_classes = renderers.get_ap_renderers() renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Album.objects.local().select_related("artist") queryset = (
music_models.Album.objects.local()
.prefetch_related(
"artist_credit__artist__description",
"artist_credit__artist__attachment_cover",
)
.select_related(
"description",
)
)
serializer_class = serializers.AlbumSerializer serializer_class = serializers.AlbumSerializer
lookup_field = "uuid" lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(instance.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class MusicTrackViewSet( class MusicTrackViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
): ):
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
permission_classes = []
renderer_classes = renderers.get_ap_renderers() renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Track.objects.local().select_related( queryset = (
"album__artist", "artist" music_models.Track.objects.local()
.select_related(
"album__description",
"description",
"attachment_cover",
"album__attachment_cover",
)
.prefetch_related(
"album__artist_credit__artist",
"artist_credit__artist__description",
"artist_credit__artist__attachment_cover",
"album__artist_credit__artist__attachment_cover",
)
) )
serializer_class = serializers.TrackSerializer serializer_class = serializers.TrackSerializer
lookup_field = "uuid" lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(instance.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class ChannelViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Artist.objects.local().select_related(
"description", "attachment_cover"
)
serializer_class = serializers.ArtistSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(instance.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class IndexViewSet(FederationMixin, viewsets.GenericViewSet):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
def dispatch(self, request, *args, **kwargs):
if not preferences.get("federation__public_index"):
return HttpResponse(status=405)
return super().dispatch(request, *args, **kwargs)
@action(
methods=["get"],
detail=False,
)
def libraries(self, request, *args, **kwargs):
libraries = (
music_models.Library.objects.local()
.filter(channel=None, privacy_level="everyone")
.prefetch_related("actor")
.order_by("creation_date")
)
conf = {
"id": federation_utils.full_url(
reverse("federation:index:index-libraries")
),
"items": libraries,
"item_serializer": serializers.LibrarySerializer,
"page_size": 100,
"actor": None,
}
return get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response.Response({}, status=200)
@action(
methods=["get"],
detail=False,
)
def channels(self, request, *args, **kwargs):
actors = (
models.Actor.objects.local()
.exclude(channel=None)
.order_by("channel__creation_date")
.prefetch_related(
"channel__attributed_to",
"channel__artist",
"channel__artist__description",
"channel__artist__attachment_cover",
)
)
conf = {
"id": federation_utils.full_url(reverse("federation:index:index-channels")),
"items": actors,
"item_serializer": serializers.ActorSerializer,
"page_size": 100,
"actor": None,
}
return get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response.Response({}, status=200)
class TrackFavoriteViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
permission_classes = [common_permissions.PrivacyLevelPermission]
renderer_classes = renderers.get_ap_renderers()
queryset = favorites_models.TrackFavorite.objects.local().select_related(
"track", "actor"
)
serializer_class = serializers.TrackFavoriteSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(instance.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class ListeningViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
permission_classes = [common_permissions.PrivacyLevelPermission]
renderer_classes = renderers.get_ap_renderers()
queryset = history_models.Listening.objects.local().select_related("track", "actor")
serializer_class = serializers.ListeningSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(instance.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class PlaylistViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = playlists_models.Playlist.objects.local().select_related("actor")
serializer_class = serializers.PlaylistCollectionSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
playlist = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(playlist.get_absolute_url())
conf = {
"id": playlist.fid,
"actor": playlist.actor,
"name": playlist.name,
"items": playlist.playlist_tracks.order_by("index").prefetch_related(
"track",
),
"item_serializer": serializers.PlaylistTrackSerializer,
"library": playlist.library.fid,
}
return get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.PlaylistCollectionSerializer(playlist),
page_access_check=lambda: has_playlist_access(request, playlist),
)
class PlaylistTrackViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = playlists_models.PlaylistTrack.objects.local().select_related("actor")
serializer_class = serializers.PlaylistTrackSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
plt = self.get_object()
if not has_playlist_access(request, plt.playlist):
return response.Response(status=403)
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(plt.get_absolute_url())
serializer = self.get_serializer(plt)
return response.Response(serializer.data)
...@@ -30,7 +30,7 @@ def clean_acct(acct_string, ensure_local=True): ...@@ -30,7 +30,7 @@ def clean_acct(acct_string, ensure_local=True):
raise forms.ValidationError("Invalid format") raise forms.ValidationError("Invalid format")
if ensure_local and hostname.lower() != settings.FEDERATION_HOSTNAME: if ensure_local and hostname.lower() != settings.FEDERATION_HOSTNAME:
raise forms.ValidationError("Invalid hostname {}".format(hostname)) raise forms.ValidationError(f"Invalid hostname {hostname}")
return username, hostname return username, hostname
...@@ -41,10 +41,17 @@ def get_resource(resource_string): ...@@ -41,10 +41,17 @@ def get_resource(resource_string):
url = "https://{}/.well-known/webfinger?resource={}".format( url = "https://{}/.well-known/webfinger?resource={}".format(
hostname, resource_string hostname, resource_string
) )
response = session.get_session().get( response = session.get_session().get(url)
url, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, timeout=5
)
response.raise_for_status() response.raise_for_status()
serializer = serializers.ActorWebfingerSerializer(data=response.json()) serializer = serializers.ActorWebfingerSerializer(data=response.json())
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
return serializer.validated_data return serializer.validated_data
def get_ap_url(links):
for link in links:
if (
link.get("rel") == "self"
and link.get("type") == "application/activity+json"
):
return link["href"]
...@@ -8,7 +8,7 @@ record.registry.register_serializer(serializers.ListeningActivitySerializer) ...@@ -8,7 +8,7 @@ record.registry.register_serializer(serializers.ListeningActivitySerializer)
@record.registry.register_consumer("history.Listening") @record.registry.register_consumer("history.Listening")
def broadcast_listening_to_instance_activity(data, obj): def broadcast_listening_to_instance_activity(data, obj):
if obj.user.privacy_level not in ["instance", "everyone"]: if obj.actor.user.privacy_level not in ["instance", "everyone"]:
return return
channels.group_send( channels.group_send(
......
...@@ -5,6 +5,6 @@ from . import models ...@@ -5,6 +5,6 @@ from . import models
@admin.register(models.Listening) @admin.register(models.Listening)
class ListeningAdmin(admin.ModelAdmin): class ListeningAdmin(admin.ModelAdmin):
list_display = ["track", "creation_date", "user", "session_key"] list_display = ["track", "creation_date", "actor", "session_key"]
search_fields = ["track__name", "user__username"] search_fields = ["track__name", "actor__user__username"]
list_select_related = ["user", "track"] list_select_related = ["actor", "track"]
import factory import factory
from django.conf import settings
from funkwhale_api.factories import registry, NoUpdateOnCreate from funkwhale_api.factories import NoUpdateOnCreate, registry
from funkwhale_api.federation import models
from funkwhale_api.federation.factories import ActorFactory
from funkwhale_api.music import factories from funkwhale_api.music import factories
from funkwhale_api.users.factories import UserFactory
@registry.register @registry.register
class ListeningFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): class ListeningFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
user = factory.SubFactory(UserFactory) actor = factory.SubFactory(ActorFactory)
track = factory.SubFactory(factories.TrackFactory) track = factory.SubFactory(factories.TrackFactory)
fid = factory.Faker("federation_url")
uuid = factory.Faker("uuid4")
privacy_level = "everyone"
class Meta: class Meta:
model = "history.Listening" model = "history.Listening"
@factory.post_generation
def local(self, create, extracted, **kwargs):
if not extracted and not kwargs:
return
domain = models.Domain.objects.get_or_create(name=settings.FEDERATION_HOSTNAME)[
0
]
self.fid = f"https://{domain}/federation/music/favorite/{self.uuid}"
self.save(update_fields=["fid"])
import django_filters
from funkwhale_api.common import filters as common_filters
from funkwhale_api.moderation import filters as moderation_filters from funkwhale_api.moderation import filters as moderation_filters
from . import models from . import models
class ListeningFilter(moderation_filters.HiddenContentFilterSet): class ListeningFilter(moderation_filters.HiddenContentFilterSet):
username = django_filters.CharFilter("actor__user__username")
domain = django_filters.CharFilter("actor__domain_id")
scope = common_filters.ActorScopeFilter(actor_field="actor", distinct=True)
class Meta: class Meta:
model = models.Listening model = models.Listening
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG[ hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG[
"LISTENING" "LISTENING"
] ]
fields = ["hidden"] fields = []
# Generated by Django 3.2.20 on 2023-12-09 14:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('history', '0002_auto_20180325_1433'),
]
operations = [
migrations.AddField(
model_name='listening',
name='source',
field=models.CharField(blank=True, max_length=100, null=True),
),
]
import uuid
from django.db import migrations, models
from django.urls import reverse
from funkwhale_api.federation import utils
import django.db.models.deletion
def get_user_actor(apps, schema_editor):
MyModel = apps.get_model("history", "Listening")
rows = []
for row in MyModel.objects.all():
actor = row.user.actor
row.actor = actor
rows.append(row)
MyModel.objects.bulk_update(rows, fields=["actor"], batch_size=5000)
def gen_uuid(apps, schema_editor):
MyModel = apps.get_model("history", "Listening")
rows = []
for row in MyModel.objects.all():
unique_uuid = uuid.uuid4()
while MyModel.objects.filter(uuid=unique_uuid).exists():
unique_uuid = uuid.uuid4()
fid = utils.full_url(
reverse("federation:music:listenings-detail", kwargs={"uuid": unique_uuid})
)
row.uuid = unique_uuid
row.fid = fid
rows.append(row)
MyModel.objects.bulk_update(rows, fields=["uuid", "fid"], batch_size=5000)
def get_user_actor(apps, schema_editor):
MyModel = apps.get_model("history", "Listening")
rows = []
for row in MyModel.objects.all():
actor = row.user.actor
row.actor = actor
rows.append(row)
MyModel.objects.bulk_update(rows, fields=["actor"], batch_size=5000)
class Migration(migrations.Migration):
dependencies = [
("history", "0003_listening_source"),
("federation", "0028_auto_20221027_1141"),
]
operations = [
migrations.AddField(
model_name="listening",
name="actor",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="listenings",
to="federation.actor",
),
),
migrations.AddField(
model_name="listening",
name="fid",
field=models.URLField(
max_length=500,
null=True,
),
),
migrations.AddField(
model_name="listening",
name="url",
field=models.URLField(blank=True, max_length=500, null=True),
),
migrations.AddField(
model_name="listening",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, null=True),
),
migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name="listening",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, unique=True),
),
migrations.AlterField(
model_name="listening",
name="fid",
field=models.URLField(
unique=True,
db_index=True,
max_length=500,
),
),
migrations.RunPython(get_user_actor, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(
model_name="listening",
name="user",
),
migrations.AlterField(
model_name="listening",
name="actor",
field=models.ForeignKey(
blank=False,
null=False,
on_delete=django.db.models.deletion.CASCADE,
related_name="listenings",
to="federation.actor",
),
),
]
# Generated by Django 5.1.6 on 2025-09-12 08:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("history", "0004_listening_actor_listening_fid_listening_url"),
]
operations = [
migrations.AddField(
model_name="listening",
name="privacy_level",
field=models.CharField(
choices=[
("me", "Only me"),
("followers", "Me and my followers"),
("instance", "Everyone on my instance, and my followers"),
("everyone", "Everyone, including people on other instances"),
],
max_length=30,
default="me",
),
),
]
import uuid
from django.db import models from django.db import models
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from funkwhale_api.common import fields
from funkwhale_api.common import models as common_models
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music.models import Track from funkwhale_api.music.models import Track
class Listening(models.Model): class ListeningQuerySet(models.QuerySet, common_models.LocalFromFidQuerySet):
pass
class Listening(federation_models.FederationMixin):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
creation_date = models.DateTimeField(default=timezone.now, null=True, blank=True) creation_date = models.DateTimeField(default=timezone.now, null=True, blank=True)
track = models.ForeignKey( track = models.ForeignKey(
Track, related_name="listenings", on_delete=models.CASCADE Track, related_name="listenings", on_delete=models.CASCADE
) )
user = models.ForeignKey( actor = models.ForeignKey(
"users.User", "federation.Actor",
related_name="listenings", related_name="listenings",
null=True,
blank=True,
on_delete=models.CASCADE, on_delete=models.CASCADE,
null=False,
blank=False,
) )
privacy_level = fields.get_privacy_field()
session_key = models.CharField(max_length=100, null=True, blank=True) session_key = models.CharField(max_length=100, null=True, blank=True)
source = models.CharField(max_length=100, null=True, blank=True)
federation_namespace = "listenings"
objects = ListeningQuerySet.as_manager()
class Meta: class Meta:
ordering = ("-creation_date",) ordering = ("-creation_date",)
def get_activity_url(self): def get_activity_url(self):
return "{}/listenings/tracks/{}".format(self.user.get_activity_url(), self.pk) return f"{self.actor.get_absolute_url()}/listenings/tracks/{self.pk}"
def get_absolute_url(self):
return f"/library/tracks/{self.track.pk}"
def get_federation_id(self):
if self.fid:
return self.fid
return federation_utils.full_url(
reverse(
f"federation:music:{self.federation_namespace}-detail",
kwargs={"uuid": self.uuid},
)
)
def save(self, **kwargs):
if not self.pk and not self.fid:
self.fid = self.get_federation_id()
if not self.privacy_level:
self.privacy_level = self.actor.user.privacy_level
return super().save(**kwargs)