Compare commits
1 Commits
main
...
providenz/banner
| Author | SHA1 | Date | |
|---|---|---|---|
| 40b2f77c55 |
@@ -0,0 +1,65 @@
|
||||
"""Document parsers for RAG backends."""
|
||||
|
||||
import logging
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
|
||||
from chat.agent_rag.document_converter.markitdown import DocumentConverter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseParser:
|
||||
"""Base class for document parsers."""
|
||||
|
||||
def parse_document(self, name: str, content_type: str, content: bytes) -> str:
|
||||
"""
|
||||
Parse the document and prepare it for the search operation.
|
||||
This method should handle the logic to convert the document
|
||||
into a format suitable for storage.
|
||||
|
||||
Args:
|
||||
name (str): The name of the document.
|
||||
content_type (str): The MIME type of the document (e.g., "application/pdf").
|
||||
content (bytes): The content of the document as a bytes stream.
|
||||
|
||||
Returns:
|
||||
str: The document content in Markdown format.
|
||||
"""
|
||||
raise NotImplementedError("Must be implemented in subclass.")
|
||||
|
||||
|
||||
class AlbertParser(BaseParser):
|
||||
"""Document parser using Albert API for PDFs and DocumentConverter for other formats."""
|
||||
|
||||
endpoint = urljoin(settings.ALBERT_API_URL, "/v1/parse-beta")
|
||||
|
||||
def parse_pdf_document(self, name: str, content_type: str, content: bytes) -> str:
|
||||
"""Parse PDF document using Albert API."""
|
||||
response = requests.post(
|
||||
self.endpoint,
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.ALBERT_API_KEY}",
|
||||
},
|
||||
files={
|
||||
"file": (name, content, content_type),
|
||||
"output_format": (None, "markdown"),
|
||||
},
|
||||
timeout=settings.ALBERT_API_PARSE_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return "\n\n".join(
|
||||
document_page["content"] for document_page in response.json().get("data", [])
|
||||
)
|
||||
|
||||
def parse_document(self, name: str, content_type: str, content: bytes) -> str:
|
||||
"""Parse document based on content type."""
|
||||
if content_type == "application/pdf":
|
||||
return self.parse_pdf_document(name=name, content_type=content_type, content=content)
|
||||
return DocumentConverter().convert_raw(
|
||||
name=name, content_type=content_type, content=content
|
||||
)
|
||||
@@ -353,6 +353,7 @@ class Base(BraveSettings, Configuration):
|
||||
# Third party apps
|
||||
"corsheaders",
|
||||
"django_filters",
|
||||
"solo.apps.SoloAppConfig",
|
||||
"dockerflow.django",
|
||||
"rest_framework",
|
||||
"parler",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import admin as auth_admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from solo.admin import SingletonModelAdmin
|
||||
from . import models
|
||||
|
||||
|
||||
@@ -107,3 +107,26 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
"allow_conversation_analytics",
|
||||
)
|
||||
list_editable = ("allow_conversation_analytics",)
|
||||
|
||||
|
||||
@admin.register(models.SiteConfiguration)
|
||||
class SiteConfigurationAdmin(SingletonModelAdmin):
|
||||
fieldsets = (
|
||||
(_("Environment Banner"), {
|
||||
"description": _("Inform users about the current environment (staging, demo...)"),
|
||||
"fields": (
|
||||
"environment_banner_level",
|
||||
"environment_banner_title",
|
||||
"environment_banner_content",
|
||||
),
|
||||
}),
|
||||
(_("Status / Incident Banner"), {
|
||||
"description": _("Communicate maintenance windows or ongoing incidents"),
|
||||
"fields": (
|
||||
"status_banner_level",
|
||||
"status_banner_title",
|
||||
"status_banner_content",
|
||||
"status_banner_dismissible",
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -225,6 +225,8 @@ class ConfigView(drf.views.APIView):
|
||||
|
||||
dict_settings["chat_upload_accept"] = ",".join(settings.RAG_FILES_ACCEPTED_FORMATS)
|
||||
|
||||
dict_settings["banners"] = self._get_banners()
|
||||
|
||||
return drf.response.Response(dict_settings)
|
||||
|
||||
def _load_theme_customization(self):
|
||||
@@ -257,3 +259,30 @@ class ConfigView(drf.views.APIView):
|
||||
)
|
||||
|
||||
return theme_customization
|
||||
|
||||
def _get_banners(self):
|
||||
"""Return active banners from SiteConfiguration."""
|
||||
|
||||
|
||||
config = models.SiteConfiguration.get_solo()
|
||||
banners = {}
|
||||
|
||||
if config.environment_banner_enabled :
|
||||
banners["environment"] = {
|
||||
"type": "environment",
|
||||
"level": config.environment_banner_level,
|
||||
"title": config.environment_banner_title,
|
||||
"content": config.environment_banner_content,
|
||||
"dismissible": False,
|
||||
}
|
||||
|
||||
if config.status_banner_enabled :
|
||||
banners["status"] = {
|
||||
"type": "status",
|
||||
"level": config.status_banner_level,
|
||||
"title": config.status_banner_title,
|
||||
"content": config.status_banner_content,
|
||||
"dismissible": config.status_banner_dismissible,
|
||||
}
|
||||
|
||||
return banners
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-29 10:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0003_alter_user_short_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SiteConfiguration',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('environment_banner_title', models.CharField(blank=True, verbose_name='Environment banner - title')),
|
||||
('environment_banner_content', models.TextField(blank=True, verbose_name='Environment banner - content')),
|
||||
('environment_banner_level', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('alert', 'Alert')], default='info', max_length=20, verbose_name='Environment banner - level')),
|
||||
('status_banner_title', models.CharField(blank=True, verbose_name='Status banner - title')),
|
||||
('status_banner_content', models.TextField(blank=True, verbose_name='Status banner - content')),
|
||||
('status_banner_level', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('alert', 'Alert')], default='info', max_length=20, verbose_name='Status banner - level')),
|
||||
('status_banner_dismissible', models.BooleanField(default=True, help_text='Allow users to dismiss this banner', verbose_name='Status banner - dismissible')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Site Configuration',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
from .base import BaseModel
|
||||
from .users import User, DuplicateEmailError,UserManager
|
||||
from .app_config import SiteConfiguration
|
||||
@@ -0,0 +1,64 @@
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from solo.models import SingletonModel
|
||||
|
||||
|
||||
class BannerLevelChoice(models.TextChoices):
|
||||
INFO = "info", _("Info")
|
||||
WARNING = "warning", _("Warning")
|
||||
ALERT = "alert", _("Alert")
|
||||
|
||||
|
||||
class SiteConfiguration(SingletonModel):
|
||||
"""Site-wide configuration for banners and announcements."""
|
||||
|
||||
# Environment banner (staging, demo, QA, sandbox, etc.)
|
||||
environment_banner_title = models.CharField(
|
||||
blank=True,
|
||||
verbose_name=_("Environment banner - title"),
|
||||
)
|
||||
environment_banner_content = models.TextField(
|
||||
blank=True,
|
||||
verbose_name=_("Environment banner - content"),
|
||||
)
|
||||
environment_banner_level = models.CharField(
|
||||
verbose_name=_("Environment banner - level"),
|
||||
max_length=20,
|
||||
choices=BannerLevelChoice.choices,
|
||||
default=BannerLevelChoice.INFO,
|
||||
)
|
||||
|
||||
# Status/incident banner
|
||||
status_banner_title = models.CharField(
|
||||
blank=True,
|
||||
verbose_name=_("Status banner - title"),
|
||||
)
|
||||
status_banner_content = models.TextField(
|
||||
blank=True,
|
||||
verbose_name=_("Status banner - content"),
|
||||
)
|
||||
status_banner_level = models.CharField(
|
||||
verbose_name=_("Status banner - level"),
|
||||
max_length=20,
|
||||
choices=BannerLevelChoice.choices,
|
||||
default=BannerLevelChoice.INFO,
|
||||
)
|
||||
status_banner_dismissible = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Status banner - dismissible"),
|
||||
help_text=_("Allow users to dismiss this banner"),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return "Site Configuration"
|
||||
|
||||
@property
|
||||
def environment_banner_enabled(self):
|
||||
return bool(self.environment_banner_title or self.environment_banner_content)
|
||||
|
||||
@property
|
||||
def status_banner_enabled(self):
|
||||
return bool(self.status_banner_title or self.status_banner_content)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Site Configuration" )
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Declare and configure base models for the conversations core application
|
||||
"""
|
||||
from django.db import models
|
||||
import uuid
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
class BaseModel(models.Model):
|
||||
"""
|
||||
Serves as an abstract base model for other models, ensuring that records are validated
|
||||
before saving as Django doesn't do it by default.
|
||||
|
||||
Includes fields common to all models: a UUID primary key and creation/update timestamps.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(
|
||||
verbose_name=_("id"),
|
||||
help_text=_("primary key for the record as UUID"),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
)
|
||||
created_at = models.DateTimeField(
|
||||
verbose_name=_("created on"),
|
||||
help_text=_("date and time at which a record was created"),
|
||||
auto_now_add=True,
|
||||
editable=False,
|
||||
)
|
||||
updated_at = models.DateTimeField(
|
||||
verbose_name=_("updated on"),
|
||||
help_text=_("date and time at which a record was last updated"),
|
||||
auto_now=True,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
class Meta: # pylint:disable=missing-class-docstring
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Call `full_clean` before saving."""
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
Declare and configure the models for the conversations core application
|
||||
Declare and configure the user models for the conversations core application
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
@@ -14,6 +14,8 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
from .base import BaseModel
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@@ -27,42 +29,6 @@ class DuplicateEmailError(Exception):
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
"""
|
||||
Serves as an abstract base model for other models, ensuring that records are validated
|
||||
before saving as Django doesn't do it by default.
|
||||
|
||||
Includes fields common to all models: a UUID primary key and creation/update timestamps.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(
|
||||
verbose_name=_("id"),
|
||||
help_text=_("primary key for the record as UUID"),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
)
|
||||
created_at = models.DateTimeField(
|
||||
verbose_name=_("created on"),
|
||||
help_text=_("date and time at which a record was created"),
|
||||
auto_now_add=True,
|
||||
editable=False,
|
||||
)
|
||||
updated_at = models.DateTimeField(
|
||||
verbose_name=_("updated on"),
|
||||
help_text=_("date and time at which a record was last updated"),
|
||||
auto_now=True,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
class Meta: # pylint:disable=missing-class-docstring
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Call `full_clean` before saving."""
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class UserManager(auth_models.UserManager):
|
||||
"""Custom manager for User model with additional methods."""
|
||||
@@ -67,6 +67,7 @@ dependencies = [
|
||||
"trafilatura==2.0.0",
|
||||
"uvicorn==0.38.0",
|
||||
"whitenoise==6.11.0",
|
||||
"django-solo>=2.5.1",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
Generated
+14
@@ -407,6 +407,7 @@ dependencies = [
|
||||
{ name = "django-parler" },
|
||||
{ name = "django-pydantic-field" },
|
||||
{ name = "django-redis" },
|
||||
{ name = "django-solo" },
|
||||
{ name = "django-storages", extra = ["s3"] },
|
||||
{ name = "django-timezone-field" },
|
||||
{ name = "djangorestframework" },
|
||||
@@ -480,6 +481,7 @@ requires-dist = [
|
||||
{ name = "django-parler", specifier = "==2.3" },
|
||||
{ name = "django-pydantic-field", specifier = "==0.4.0" },
|
||||
{ name = "django-redis", specifier = "==6.0.0" },
|
||||
{ name = "django-solo", specifier = ">=2.5.1" },
|
||||
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },
|
||||
{ name = "django-test-migrations", marker = "extra == 'dev'", specifier = "==1.5.0" },
|
||||
{ name = "django-timezone-field", specifier = ">=5.1" },
|
||||
@@ -831,6 +833,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/79/055dfcc508cfe9f439d9f453741188d633efa9eab90fc78a67b0ab50b137/django_redis-6.0.0-py3-none-any.whl", hash = "sha256:20bf0063a8abee567eb5f77f375143c32810c8700c0674ced34737f8de4e36c0", size = 33687, upload-time = "2025-06-17T18:15:34.165Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-solo"
|
||||
version = "2.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/b3/1128d46ca1956dca02b7425bd5bc753c3a31ed21be6c3bb19b8d4c5316c9/django_solo-2.5.1.tar.gz", hash = "sha256:ca3b2e44dbb4726446941ca8d765c247123f709f051d07e62eacf97330ffde01", size = 13662, upload-time = "2026-01-01T15:46:52.221Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/aa/45058bdec53ce3df577be9bd40c630067788923284becc6cca6fc592d4db/django_solo-2.5.1-py3-none-any.whl", hash = "sha256:23bfe48950587b409ec4b4ef95e5e62c1c6c590dd958d8d8b99d37d5ccb53ca8", size = 16882, upload-time = "2026-01-01T15:46:51.087Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-storages"
|
||||
version = "1.14.6"
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface ConfigResponse {
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'conversations_config';
|
||||
|
||||
function getCachedTranslation() {
|
||||
function getCachedConfig() {
|
||||
try {
|
||||
const jsonString = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
return jsonString ? (JSON.parse(jsonString) as ConfigResponse) : undefined;
|
||||
@@ -50,7 +50,7 @@ function getCachedTranslation() {
|
||||
}
|
||||
}
|
||||
|
||||
function setCachedTranslation(translations: ConfigResponse) {
|
||||
function setCachedConfig(translations: ConfigResponse) {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(translations));
|
||||
}
|
||||
|
||||
@@ -62,7 +62,8 @@ export const getConfig = async (): Promise<ConfigResponse> => {
|
||||
}
|
||||
|
||||
const config = response.json() as Promise<ConfigResponse>;
|
||||
setCachedTranslation(await config);
|
||||
|
||||
setCachedConfig(await config);
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -70,7 +71,7 @@ export const getConfig = async (): Promise<ConfigResponse> => {
|
||||
export const KEY_CONFIG = 'config';
|
||||
|
||||
export function useConfig() {
|
||||
const cachedData = getCachedTranslation();
|
||||
const cachedData = getCachedConfig();
|
||||
const oneHour = 1000 * 60 * 60;
|
||||
|
||||
return useQuery<ConfigResponse, APIError, ConfigResponse>({
|
||||
|
||||
@@ -137,6 +137,7 @@ export const InputChat = ({
|
||||
const { isDesktop, isMobile } = useResponsiveStore();
|
||||
|
||||
const { data: conf } = useConfig();
|
||||
|
||||
const { isFeatureFlagActivated } = useAnalytics();
|
||||
const [fileUploadEnabled, setFileUploadEnabled] = useState(false);
|
||||
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// hooks/useElementHeight.ts
|
||||
import { useState, useEffect, useRef, RefObject } from 'react';
|
||||
|
||||
export function useElementHeight<T extends HTMLElement>(): [
|
||||
RefObject<T>,
|
||||
number,
|
||||
] {
|
||||
const ref = useRef<T>(null);
|
||||
const [height, setHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setHeight(entry.contentRect.height);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(element);
|
||||
setHeight(element.offsetHeight); // initial measurement
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return [ref, height];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { PropsWithChildren, useEffect, useState } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box } from '@/components';
|
||||
@@ -8,11 +8,37 @@ import { Header } from '@/features/header';
|
||||
import { LeftPanel } from '@/features/left-panel';
|
||||
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
import { useElementHeight } from '@/hook/useElementHeight';
|
||||
import { FeatureFlagState, useConfig } from '@/core';
|
||||
|
||||
type MainLayoutProps = {
|
||||
backgroundColor?: 'white' | 'grey';
|
||||
};
|
||||
|
||||
const Banner = ({
|
||||
title = '',
|
||||
content = '',
|
||||
}: {
|
||||
title?: string;
|
||||
content?: string;
|
||||
}) => {
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
$css={css`
|
||||
background-color: #ccc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`}
|
||||
>
|
||||
<strong>{title}</strong>
|
||||
<p>{content}</p>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export function MainLayout({
|
||||
children,
|
||||
backgroundColor: _backgroundColor = 'white',
|
||||
@@ -21,15 +47,41 @@ export function MainLayout({
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const { isPanelOpen } = useChatPreferencesStore();
|
||||
|
||||
const HEADER_HEIGHT = `${isDesktop ? '65' : '52'}`;
|
||||
const [banners, setBanners] = useState({});
|
||||
const { data: conf } = useConfig();
|
||||
|
||||
const [bannerRef, bannerHeight] = useElementHeight<HTMLDivElement>();
|
||||
|
||||
useEffect(() => {
|
||||
setBanners(conf?.banners ?? {});
|
||||
}, [conf]);
|
||||
|
||||
const HEADER_HEIGHT = `${isDesktop ? '65' : '52'}`;
|
||||
console.log(banners);
|
||||
console.log(bannerHeight);
|
||||
return (
|
||||
<Box className="--docs--main-layout">
|
||||
<Box
|
||||
ref={bannerRef}
|
||||
$css={css`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1001;
|
||||
background-color: #ccc;
|
||||
`}
|
||||
>
|
||||
<Banner content={banners?.environment?.content}></Banner>
|
||||
<Banner content={banners?.status?.content}></Banner>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
$css={css`
|
||||
z-index: 1000;
|
||||
transition: left 0.3s ease;
|
||||
position: fixed;
|
||||
top: ${bannerHeight}px;
|
||||
width: 300px;
|
||||
left: ${isPanelOpen ? '0px' : '-300px'};
|
||||
`}
|
||||
@@ -40,6 +92,7 @@ export function MainLayout({
|
||||
$css={css`
|
||||
transition: all 0.3s ease;
|
||||
position: fixed;
|
||||
top: ${bannerHeight}px;
|
||||
left: ${isDesktop && isPanelOpen ? '300px' : '0px'};
|
||||
width: calc(100vw - ${isDesktop && isPanelOpen ? '300px' : '0px'});
|
||||
`}
|
||||
|
||||
@@ -52,6 +52,7 @@ export const overrideConfig = async (
|
||||
) =>
|
||||
await page.route('**/api/v1.0/config/', async (route) => {
|
||||
const request = route.request();
|
||||
console.log("CONFIG")
|
||||
if (request.method().includes('GET')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
|
||||
+1
-1
@@ -399,7 +399,7 @@ glob-parent@~5.1.2:
|
||||
dependencies:
|
||||
is-glob "^4.0.1"
|
||||
|
||||
glob@^10.5.0:
|
||||
glob@^10.3.10, glob@^10.3.3:
|
||||
version "10.5.0"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c"
|
||||
integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==
|
||||
|
||||
Reference in New Issue
Block a user