[beers] Add beer scrobbling
This commit is contained in:
25
vrobbler/apps/beers/admin.py
Normal file
25
vrobbler/apps/beers/admin.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from beers.models import Beer
|
||||||
|
|
||||||
|
from scrobbles.admin import ScrobbleInline
|
||||||
|
|
||||||
|
|
||||||
|
class BeerInline(admin.TabularInline):
|
||||||
|
model = Beer
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Beer)
|
||||||
|
class BeerAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = (
|
||||||
|
"uuid",
|
||||||
|
"title",
|
||||||
|
"style",
|
||||||
|
)
|
||||||
|
ordering = ("-created",)
|
||||||
|
search_fields = ("title",)
|
||||||
|
inlines = [
|
||||||
|
ScrobbleInline,
|
||||||
|
]
|
||||||
5
vrobbler/apps/beers/apps.py
Normal file
5
vrobbler/apps/beers/apps.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BeersConfig(AppConfig):
|
||||||
|
name = "beers"
|
||||||
133
vrobbler/apps/beers/migrations/0001_initial.py
Normal file
133
vrobbler/apps/beers/migrations/0001_initial.py
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-10-22 21:26
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django_extensions.db.fields
|
||||||
|
import taggit.managers
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0065_alter_scrobble_log"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="BeerProducer",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
django_extensions.db.fields.CreationDateTimeField(
|
||||||
|
auto_now_add=True, verbose_name="created"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
django_extensions.db.fields.ModificationDateTimeField(
|
||||||
|
auto_now=True, verbose_name="modified"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("description", models.TextField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"location",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"get_latest_by": "modified",
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Beer",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
django_extensions.db.fields.CreationDateTimeField(
|
||||||
|
auto_now_add=True, verbose_name="created"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
django_extensions.db.fields.ModificationDateTimeField(
|
||||||
|
auto_now=True, verbose_name="modified"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(
|
||||||
|
blank=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"title",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"run_time_seconds",
|
||||||
|
models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"run_time_ticks",
|
||||||
|
models.PositiveBigIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
("description", models.TextField(blank=True, null=True)),
|
||||||
|
("ibu", models.SmallIntegerField(blank=True, null=True)),
|
||||||
|
("abv", models.FloatField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"style",
|
||||||
|
models.CharField(blank=True, max_length=100, null=True),
|
||||||
|
),
|
||||||
|
("non_alcoholic", models.BooleanField(default=False)),
|
||||||
|
(
|
||||||
|
"beeradvocate_id",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"beeradvocate_score",
|
||||||
|
models.SmallIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"untappd_id",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"genre",
|
||||||
|
taggit.managers.TaggableManager(
|
||||||
|
blank=True,
|
||||||
|
help_text="A comma-separated list of tags.",
|
||||||
|
through="scrobbles.ObjectWithGenres",
|
||||||
|
to="scrobbles.Genre",
|
||||||
|
verbose_name="Tags",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
vrobbler/apps/beers/migrations/__init__.py
Normal file
0
vrobbler/apps/beers/migrations/__init__.py
Normal file
41
vrobbler/apps/beers/models.py
Normal file
41
vrobbler/apps/beers/models.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
from django.apps import apps
|
||||||
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
|
from django_extensions.db.models import TimeStampedModel
|
||||||
|
from scrobbles.dataclasses import BeerLogData
|
||||||
|
from scrobbles.mixins import ScrobblableMixin
|
||||||
|
|
||||||
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
|
||||||
|
class BeerProducer(TimeStampedModel):
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
location = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
|
|
||||||
|
class Beer(ScrobblableMixin):
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
ibu = models.SmallIntegerField(**BNULL)
|
||||||
|
abv = models.FloatField(**BNULL)
|
||||||
|
style = models.CharField(max_length=100, **BNULL)
|
||||||
|
non_alcoholic = models.BooleanField(default=False)
|
||||||
|
beeradvocate_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
beeradvocate_score = models.SmallIntegerField(**BNULL)
|
||||||
|
untappd_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse("beers:beer_detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logdata_cls(self):
|
||||||
|
return BeerLogData
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_or_create(cls, title: str) -> "Beer":
|
||||||
|
return cls.objects.filter(title=title).first()
|
||||||
|
|
||||||
|
def scrobbles(self, user_id):
|
||||||
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||||
|
return Scrobble.objects.filter(
|
||||||
|
user_id=user_id, life_event=self
|
||||||
|
).order_by("-timestamp")
|
||||||
14
vrobbler/apps/beers/urls.py
Normal file
14
vrobbler/apps/beers/urls.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from beers import views
|
||||||
|
|
||||||
|
app_name = "beers"
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("beers/", views.BeerListView.as_view(), name="beer_list"),
|
||||||
|
path(
|
||||||
|
"beers/<slug:slug>/",
|
||||||
|
views.BeerDetailView.as_view(),
|
||||||
|
name="beer_detail",
|
||||||
|
),
|
||||||
|
]
|
||||||
11
vrobbler/apps/beers/views.py
Normal file
11
vrobbler/apps/beers/views.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
from beers.models import Beer
|
||||||
|
|
||||||
|
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||||
|
|
||||||
|
|
||||||
|
class BeerListView(ScrobbleableListView):
|
||||||
|
model = Beer
|
||||||
|
|
||||||
|
|
||||||
|
class BeerDetailView(ScrobbleableDetailView):
|
||||||
|
model = Beer
|
||||||
@ -17,6 +17,7 @@ PLAY_AGAIN_MEDIA = {
|
|||||||
"moods": "Mood",
|
"moods": "Mood",
|
||||||
"bricksets": "BrickSet",
|
"bricksets": "BrickSet",
|
||||||
"trails": "Trail",
|
"trails": "Trail",
|
||||||
|
"beers": "Beer",
|
||||||
}
|
}
|
||||||
|
|
||||||
MEDIA_END_PADDING_SECONDS = {
|
MEDIA_END_PADDING_SECONDS = {
|
||||||
|
|||||||
@ -35,6 +35,7 @@ class JSONDataclass(JSONWizard):
|
|||||||
class ScrobbleLogData(JSONDataclass):
|
class ScrobbleLogData(JSONDataclass):
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class LongPlayLogData(JSONDataclass):
|
class LongPlayLogData(JSONDataclass):
|
||||||
serial_scrobble_id: Optional[int]
|
serial_scrobble_id: Optional[int]
|
||||||
long_play_complete: bool = False
|
long_play_complete: bool = False
|
||||||
@ -193,3 +194,12 @@ class TrailLogData(WithOthersLogData):
|
|||||||
details: Optional[str] = None
|
details: Optional[str] = None
|
||||||
effort: Optional[str] = None
|
effort: Optional[str] = None
|
||||||
difficulty: Optional[str] = None
|
difficulty: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BeerLogData(WithOthersLogData):
|
||||||
|
with_user_ids: Optional[list[int]] = None
|
||||||
|
with_names_str: Optional[list[str]] = None
|
||||||
|
details: Optional[str] = None
|
||||||
|
rating: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|||||||
@ -0,0 +1,50 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-10-22 21:26
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("beers", "0001_initial"),
|
||||||
|
("scrobbles", "0065_alter_scrobble_log"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="beer",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
to="beers.beer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="scrobble",
|
||||||
|
name="media_type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("Video", "Video"),
|
||||||
|
("Track", "Track"),
|
||||||
|
("PodcastEpisode", "Podcast episode"),
|
||||||
|
("SportEvent", "Sport event"),
|
||||||
|
("Book", "Book"),
|
||||||
|
("VideoGame", "Video game"),
|
||||||
|
("BoardGame", "Board game"),
|
||||||
|
("GeoLocation", "GeoLocation"),
|
||||||
|
("Trail", "Trail"),
|
||||||
|
("Beer", "Beer"),
|
||||||
|
("Task", "Task"),
|
||||||
|
("WebPage", "Web Page"),
|
||||||
|
("LifeEvent", "Life event"),
|
||||||
|
("Mood", "Mood"),
|
||||||
|
("BrickSet", "Brick set"),
|
||||||
|
],
|
||||||
|
default="Video",
|
||||||
|
max_length=14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -39,6 +39,7 @@ from scrobbles.stats import build_charts
|
|||||||
from scrobbles.utils import media_class_to_foreign_key
|
from scrobbles.utils import media_class_to_foreign_key
|
||||||
from sports.models import SportEvent
|
from sports.models import SportEvent
|
||||||
from tasks.models import Task
|
from tasks.models import Task
|
||||||
|
from beers.models import Beer
|
||||||
from trails.models import Trail
|
from trails.models import Trail
|
||||||
from videogames import retroarch
|
from videogames import retroarch
|
||||||
from videogames.models import VideoGame
|
from videogames.models import VideoGame
|
||||||
@ -486,6 +487,7 @@ class Scrobble(TimeStampedModel):
|
|||||||
BOARD_GAME = "BoardGame", "Board game"
|
BOARD_GAME = "BoardGame", "Board game"
|
||||||
GEO_LOCATION = "GeoLocation", "GeoLocation"
|
GEO_LOCATION = "GeoLocation", "GeoLocation"
|
||||||
TRAIL = "Trail", "Trail"
|
TRAIL = "Trail", "Trail"
|
||||||
|
BEER = "Beer", "Beer"
|
||||||
TASK = "Task", "Task"
|
TASK = "Task", "Task"
|
||||||
WEBPAGE = "WebPage", "Web Page"
|
WEBPAGE = "WebPage", "Web Page"
|
||||||
LIFE_EVENT = "LifeEvent", "Life event"
|
LIFE_EVENT = "LifeEvent", "Life event"
|
||||||
@ -511,6 +513,7 @@ class Scrobble(TimeStampedModel):
|
|||||||
geo_location = models.ForeignKey(
|
geo_location = models.ForeignKey(
|
||||||
GeoLocation, on_delete=models.DO_NOTHING, **BNULL
|
GeoLocation, on_delete=models.DO_NOTHING, **BNULL
|
||||||
)
|
)
|
||||||
|
beer = models.ForeignKey(Beer, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
trail = models.ForeignKey(Trail, on_delete=models.DO_NOTHING, **BNULL)
|
trail = models.ForeignKey(Trail, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
task = models.ForeignKey(Task, on_delete=models.DO_NOTHING, **BNULL)
|
task = models.ForeignKey(Task, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
web_page = models.ForeignKey(WebPage, on_delete=models.DO_NOTHING, **BNULL)
|
web_page = models.ForeignKey(WebPage, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
@ -574,8 +577,11 @@ class Scrobble(TimeStampedModel):
|
|||||||
@property
|
@property
|
||||||
def last_serial_scrobble(self) -> Optional["Scrobble"]:
|
def last_serial_scrobble(self) -> Optional["Scrobble"]:
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
if self.logdata and self.logdata.serial_scrobble_id:
|
if self.logdata and self.logdata.serial_scrobble_id:
|
||||||
return Scrobble.objects.filter(id=self.logdata.serial_scrobble_id).first()
|
return Scrobble.objects.filter(
|
||||||
|
id=self.logdata.serial_scrobble_id
|
||||||
|
).first()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.uuid:
|
if not self.uuid:
|
||||||
|
|||||||
@ -113,6 +113,7 @@ INSTALLED_APPS = [
|
|||||||
"webpages",
|
"webpages",
|
||||||
"tasks",
|
"tasks",
|
||||||
"trails",
|
"trails",
|
||||||
|
"beers",
|
||||||
"lifeevents",
|
"lifeevents",
|
||||||
"moods",
|
"moods",
|
||||||
"mathfilters",
|
"mathfilters",
|
||||||
|
|||||||
@ -130,6 +130,7 @@ INSTALLED_APPS = [
|
|||||||
"webpages",
|
"webpages",
|
||||||
"tasks",
|
"tasks",
|
||||||
"trails",
|
"trails",
|
||||||
|
"beers",
|
||||||
"lifeevents",
|
"lifeevents",
|
||||||
"moods",
|
"moods",
|
||||||
"mathfilters",
|
"mathfilters",
|
||||||
|
|||||||
68
vrobbler/templates/beers/beer_detail.html
Normal file
68
vrobbler/templates/beers/beer_detail.html
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
{% load mathfilters %}
|
||||||
|
{% load static %}
|
||||||
|
{% load naturalduration %}
|
||||||
|
|
||||||
|
{% block title %}{{object.title}}{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<style>
|
||||||
|
.cover img {
|
||||||
|
width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
float: left;
|
||||||
|
width: 252px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
float: left;
|
||||||
|
width: 600px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="summary">
|
||||||
|
{% if object.description%}
|
||||||
|
<p>{{object.description|safe|linebreaks|truncatewords:160}}</p>
|
||||||
|
<hr />
|
||||||
|
{% endif %}
|
||||||
|
<p style="float:right;">
|
||||||
|
<a href="{{object.beeradvocate_link}}"><img src="{% static "images/beeradvoate-logo.png" %}" width=35></a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<p>{{object.scrobble_set.count}} scrobbles</p>
|
||||||
|
<p>
|
||||||
|
<a href="{{object.get_start_url}}">Drink again</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<h3>Last scrobbles</h3>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for scrobble in object.scrobble_set.all|dictsortreversed:"timestamp" %}
|
||||||
|
<tr>
|
||||||
|
<td>{{scrobble.timestamp}}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
23
vrobbler/templates/beers/beer_list.html
Normal file
23
vrobbler/templates/beers/beer_list.html
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
|
||||||
|
{% block title %}Beers{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<style>
|
||||||
|
dl { width: 210px; float:left; margin-right: 10px; }
|
||||||
|
dt a { color:white; text-decoration: none; font-size:smaller; }
|
||||||
|
img { height:200px; width: 200px; object-fit: cover; }
|
||||||
|
dd .right { float:right; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
<div class="row">
|
||||||
|
|
||||||
|
<div class="col-md">
|
||||||
|
<div class="table-responsive">
|
||||||
|
{% include "_scrobblable_list.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -37,6 +37,7 @@ from vrobbler.apps.sports.api.views import (
|
|||||||
)
|
)
|
||||||
from vrobbler.apps.tasks import urls as tasks_urls
|
from vrobbler.apps.tasks import urls as tasks_urls
|
||||||
from vrobbler.apps.trails import urls as trails_urls
|
from vrobbler.apps.trails import urls as trails_urls
|
||||||
|
from vrobbler.apps.beers import urls as beers_urls
|
||||||
from vrobbler.apps.videogames import urls as videogame_urls
|
from vrobbler.apps.videogames import urls as videogame_urls
|
||||||
from vrobbler.apps.videos import urls as video_urls
|
from vrobbler.apps.videos import urls as video_urls
|
||||||
from vrobbler.apps.videos.api.views import SeriesViewSet, VideoViewSet
|
from vrobbler.apps.videos.api.views import SeriesViewSet, VideoViewSet
|
||||||
@ -78,6 +79,7 @@ urlpatterns = [
|
|||||||
path("", include(sports_urls, namespace="sports")),
|
path("", include(sports_urls, namespace="sports")),
|
||||||
path("", include(locations_urls, namespace="locations")),
|
path("", include(locations_urls, namespace="locations")),
|
||||||
path("", include(trails_urls, namespace="trails")),
|
path("", include(trails_urls, namespace="trails")),
|
||||||
|
path("", include(beers_urls, namespace="beers")),
|
||||||
path("", include(tasks_urls, namespace="tasks")),
|
path("", include(tasks_urls, namespace="tasks")),
|
||||||
path("", include(webpages_urls, namespace="webpages")),
|
path("", include(webpages_urls, namespace="webpages")),
|
||||||
path("", include(podcast_urls, namespace="podcasts")),
|
path("", include(podcast_urls, namespace="podcasts")),
|
||||||
|
|||||||
Reference in New Issue
Block a user