Sst main dev

dev
Timofey Malinin 2 years ago
parent d782a76478
commit 4f9b411688

@ -32,6 +32,7 @@ services:
python manage.py loaddata fixtures/groups.json &&
python manage.py loaddata fixtures/post_and_pvz.json &&
python manage.py loaddata fixtures/post_and_pvz_groups.json &&
python manage.py loaddata fixtures/post_pvz_groups.json &&
python manage.py loaddata fixtures/otherobjectscategorys.json &&
python manage.py loaddata fixtures/otherobjectsgroups.json &&
python manage.py runserver 0.0.0.0:${DJANGO_PORT}"

@ -43,8 +43,8 @@ INSTALLED_APPS = [
'rest_framework',
'django_json_widget',
'django.contrib.gis',
'rest_registration',
'django_celery_beat',
'drf_keycloak_auth',
]
MIDDLEWARE = [
@ -147,23 +147,6 @@ if os.getenv('local') is not None:
GDAL_LIBRARY_PATH = '/opt/homebrew/opt/gdal/lib/libgdal.dylib'
GEOS_LIBRARY_PATH = '/opt/homebrew/opt/geos/lib/libgeos_c.dylib'
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = os.getenv('EMAIL_HOST', 'smtp.yandex.ru')
EMAIL_PORT = os.getenv('EMAIL_PORT', 587)
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', 'noreply@spatiality.website')
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', 'spatialitypass321')
EMAIL_USE_TLS = True
FRONTEND_URL = os.getenv('REACT_APP_DOMAIN_URL', 'http://localhost:3000/')
REST_REGISTRATION = {
'REGISTER_VERIFICATION_ENABLED': True,
'RESET_PASSWORD_VERIFICATION_ENABLED': False,
'REGISTER_EMAIL_VERIFICATION_ENABLED': True,
'REGISTER_VERIFICATION_URL': f'{FRONTEND_URL}verify-user/',
'RESET_PASSWORD_VERIFICATION_URL': f'{FRONTEND_URL}reset-password/',
'REGISTER_EMAIL_VERIFICATION_URL': f'{FRONTEND_URL}verify-email/',
'VERIFICATION_FROM_EMAIL': 'noreply@spatiality.website',
'USER_LOGIN_FIELDS': ['email'],
}
SWAGGER_SETTINGS = {
'DEFAULT_INFO': 'service.urls.info',
@ -198,3 +181,25 @@ DATA_UPLOAD_MAX_NUMBER_FIELDS = None
GEOCODER_API_KEY = os.getenv('GEOCODER_API_KEY','TzgdKWgyI2nfaz1WHRD-aYJK4e400MiOJQP7Enf1e1M')
STATUS_TASK_NAME='status_task'
STATUS_TASK_NAME_IMPORT='import_status_task'
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.SessionAuthentication',
'drf_keycloak_auth.authentication.KeycloakAuthentication',
]
}
DRF_KEYCLOAK_AUTH = {
# 'KEYCLOAK_SERVER_URL': 'http://keycloak.dev.selfservicetech.ru/auth',
'KEYCLOAK_SERVER_URL': os.getenv('KEYCLOAK_SERVER_URL', 'https://kk.dev.selftech.ru/auth'),
'KEYCLOAK_REALM': os.getenv('KEYCLOAK_REALM', 'SST'),
'KEYCLOAK_CLIENT_ID': os.getenv('KEYCLOAK_CLIENT_ID','postnet'),
'KEYCLOAK_CLIENT_SECRET_KEY': os.getenv('KEYCLOAK_CLIENT_SECRET_KEY','K2yHweEUispkVeWn03VMk843sW2Moic5'),
'KEYCLOAK_MANAGE_LOCAL_USER': False,
'KEYCLOAK_ROLE_SET_PREFIX': 'realm_access',
}
KK_EDITOR_ROLE = os.getenv('KK_EDITOR_ROLE', 'postnet_editor')
KK_VIEWER_ROLE = os.getenv('KK_VIEWER_ROLE', 'postnet_viewer')

@ -8,7 +8,6 @@ from service.admin import my_admin_site
urlpatterns = [
path('admin/', my_admin_site.urls),
path('api/', include('service.urls')),
path('accounts/', include('rest_registration.api.urls')),
]
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

@ -92,3 +92,4 @@ xlrd==1.2.0
XlsxWriter==3.0.8
django-filter==23.2
shap==0.41.0
drf-keycloak-auth==0.3.0

@ -2,7 +2,11 @@ from django.core.management.base import BaseCommand
from service.utils import run_sql_command, log_to_telegram
CMD_PIVOT_DIST = """CREATE OR REPLACE VIEW compact_placementpoint AS
<<<<<<< HEAD
SELECT id, status, category, age_day, fact, area_id, district_id, prediction_first, prediction_current, doors, flat_cnt, rival_post_cnt, rival_pvz_cnt, target_post_cnt, flats_cnt, tc_cnt, culture_cnt, mfc_cnt, public_stop_cnt, supermarket_cnt, target_dist, metro_dist, geometry FROM service_placementpoint;
=======
SELECT id, status, category, age_day, fact, area_id, district_id, prediction_first, prediction_current, doors, flat_cnt, rival_post_cnt, rival_pvz_cnt, target_post_cnt, flats_cnt, tc_cnt, culture_cnt, mfc_cnt, public_stop_cnt, supermarket_cnt, target_dist, metro_dist, geometry, delta_first, delta_current FROM service_placementpoint;
>>>>>>> dev
CREATE OR REPLACE procedure pivot_dist()
AS $BODY$
DECLARE columnNames TEXT;
@ -23,7 +27,7 @@ FROM CROSSTAB(
LEFT JOIN compact_placementpoint ON placement_point_id=id'
,columnNames);
ELSE
CREATE MATERIALIZED VIEW points_with_dist AS SELECT placement_point_id, compact_placementpoint.id, status, category, age_day, fact, area_id, district_id, prediction_first, prediction_current, doors, flat_cnt, rival_post_cnt, rival_pvz_cnt, target_post_cnt, flats_cnt, tc_cnt, culture_cnt, mfc_cnt, public_stop_cnt, supermarket_cnt, target_dist, metro_dist, geometry FROM service_placementpointpvzdistance LEFT JOIN compact_placementpoint ON placement_point_id=compact_placementpoint.id;
CREATE MATERIALIZED VIEW points_with_dist AS SELECT placement_point_id, compact_placementpoint.id, status, category, age_day, fact, area_id, district_id, prediction_first, prediction_current, doors, flat_cnt, rival_post_cnt, rival_pvz_cnt, target_post_cnt, flats_cnt, tc_cnt, culture_cnt, mfc_cnt, public_stop_cnt, supermarket_cnt, target_dist, metro_dist, geometry, delta_first, delta_current FROM service_placementpointpvzdistance LEFT JOIN compact_placementpoint ON placement_point_id=compact_placementpoint.id;
END IF;
END;
$BODY$
@ -31,7 +35,7 @@ LANGUAGE plpgsql;
"""
CMD_PIVOT_DIST_PRE = """CREATE OR REPLACE VIEW compact_preplacementpoint AS
SELECT id, status, category, age_day, fact, area_id, district_id, prediction_first, prediction_current, doors, flat_cnt, rival_post_cnt, rival_pvz_cnt, target_post_cnt, flats_cnt, tc_cnt, culture_cnt, mfc_cnt, public_stop_cnt, supermarket_cnt, target_dist, metro_dist, geometry FROM service_preplacementpoint;
SELECT id, status, category, age_day, fact, area_id, district_id, prediction_first, prediction_current, doors, flat_cnt, rival_post_cnt, rival_pvz_cnt, target_post_cnt, flats_cnt, tc_cnt, culture_cnt, mfc_cnt, public_stop_cnt, supermarket_cnt, target_dist, metro_dist, geometry, delta_first, delta_current FROM service_preplacementpoint;
CREATE OR REPLACE procedure prepivot_dist()
AS $BODY$
DECLARE columnNames TEXT;
@ -52,7 +56,7 @@ FROM CROSSTAB(
LEFT JOIN compact_preplacementpoint ON preplacement_point_id=id'
,columnNames);
ELSE
CREATE MATERIALIZED VIEW prepoints_with_dist AS SELECT placement_point_id, compact_preplacementpoint.id, status, category, age_day, fact, area_id, district_id, prediction_first, prediction_current, doors, flat_cnt, rival_post_cnt, rival_pvz_cnt, target_post_cnt, flats_cnt, tc_cnt, culture_cnt, mfc_cnt, public_stop_cnt, supermarket_cnt, target_dist, metro_dist, geometry FROM service_preplacementpointpvzdistance LEFT JOIN compact_preplacementpoint ON placement_point_id=compact_preplacementpoint.id;
CREATE MATERIALIZED VIEW prepoints_with_dist AS SELECT placement_point_id, compact_preplacementpoint.id, status, category, age_day, fact, area_id, district_id, prediction_first, prediction_current, doors, flat_cnt, rival_post_cnt, rival_pvz_cnt, target_post_cnt, flats_cnt, tc_cnt, culture_cnt, mfc_cnt, public_stop_cnt, supermarket_cnt, target_dist, metro_dist, geometry, delta_first, delta_current FROM service_preplacementpointpvzdistance LEFT JOIN compact_preplacementpoint ON placement_point_id=compact_preplacementpoint.id;
END IF;
END;
$BODY$

@ -287,11 +287,11 @@ class House(models.Model):
verbose_name_plural = 'Дома'
ordering = ('id',)
year_bld = models.IntegerField(blank=True, null=True)
mat_nes = models.TextField(blank=True, null=True)
year_bld = models.IntegerField(blank=True,null=True)
mat_nes = models.TextField(blank=True,null=True)
flat_cnt = models.IntegerField(blank=True, null=True)
levels = models.TextField(blank=True, null=True)
levels = models.TextField(blank=True,null=True)
doors = models.IntegerField(blank=True, null=True)
enrg_cls = models.TextField(blank=True, null=True)
street = models.TextField(blank=True, null=True)
house_number = models.TextField(blank=True, null=True)
enrg_cls = models.TextField(blank=True,null=True)
street = models.TextField(blank=True,null=True)
house_number = models.TextField(blank=True,null=True)

@ -1,14 +1,17 @@
from rest_framework.permissions import BasePermission
# from drf_keycloak_auth.authentication import KeycloakAuthentication
from django.conf import settings
class UserPermission(BasePermission):
def has_permission(self, request, view):
if view.action in [
'update_fact', 'update_postamat_id', 'update_status', 'retrieve',
'update', 'partial_update', 'destroy', 'create',
]:
return request.user.groups.filter(name='Редактор').exists()
kk_profile = request.auth
kk_roles = kk_profile.get('realm_access', {}).get('roles', [])
if request.method not in ['GET']:
# if view.action in [
# 'update_fact', 'update_postamat_id', 'update_status', 'retrieve',
# 'update', 'partial_update', 'destroy', 'create',
# ]:
return settings.KK_EDITOR_ROLE in kk_roles
else:
return request.user.groups.filter(
name__in=('Зритель', 'Редактор'),
).exists()
return settings.KK_EDITOR_ROLE in kk_roles or settings.KK_VIEWER_ROLE in kk_roles

@ -19,6 +19,7 @@ from django.db.models import Avg, Sum, Count
class PointService:
def update_fact(self, postamat_id: str, fact: int):
qs = self.get_point_by_postamat_id(postamat_id)
qs.update(**{'fact': fact})
@ -28,7 +29,7 @@ class PointService:
qs.update(**{'postamat_id': postamat_id})
def start_mathing(self, obj_id: int, task_name=STATUS_TASK_NAME_IMPORT):
change_status('Шаг 1. Начинается мэтчинг точек', task_name)
change_status('Шаг 1 из 3 (Мэтчинг точек).', task_name)
file = models.TempFiles.objects.get(id=obj_id)
excel_file = base64.b64decode(file.data)
df = pd.read_excel(excel_file)
@ -129,7 +130,7 @@ class PointService:
matching_status=MatchingStatus.New.name,
status=PointStatus.Pending.name, area=rayon,
district=rayon.AO)
change_status(f'Шаг 1. Обработано {matched + problem} из {total}', task_name)
change_status(f'Шаг 1 из 3 (Мэчинг точек). Обработано {matched + problem} из {total}', task_name)
ts = models.TaskStatus.objects.get(task_name=task_name)
ts.data = {'total': total, 'matched': matched, 'error': problem, 'unmatched': total - matched - problem}
@ -137,11 +138,10 @@ class PointService:
# return total, matched, problem
def make_enrichment(self, task_name=STATUS_TASK_NAME_IMPORT):
change_status('Шаг 2. Начинается обогащение точек', task_name)
change_status('Шаг 2 из 3 (Обогащение точек).', task_name)
points = models.PrePlacementPoint.objects.filter(matching_status=MatchingStatus.New.name).all()
groups = models.Post_and_pvzGroup.objects.all()
for point in points:
for _i, point in enumerate(points):
origin = point.geometry
qs = models.PlacementPoint.objects.filter(status=PointStatus.Working.name).annotate(
dist=Dist('geometry', origin)).order_by('dist')
@ -157,35 +157,44 @@ class PointService:
point.rival_pvz_cnt = models.Post_and_pvz.objects.filter(
category__name="ПВЗ", include_in_ml=True,
wkt__distance_lt=(origin, Distance(m=DEFAULT_PLACEMENT_POINT_UPDATE_RADIUS))).count()
point.metro_dist = models.OtherObjects.objects.filter(group__name='metro_stations').annotate(
dist=Dist('wkt', origin)).order_by('dist')[0].dist.m
point.property_price_bargains = models.OtherObjects.objects.filter(
metro = models.OtherObjects.objects.filter(group__name='metro_stations').annotate(
dist=Dist('wkt', origin)).order_by('dist')
if metro:
point.metro_dist = metro[0].dist.m
bargains = models.OtherObjects.objects.filter(
group__name="bargains",
wkt__distance_lt=(origin, Distance(m=DEFAULT_PLACEMENT_POINT_UPDATE_RADIUS))).aggregate(Avg('param1'))[
'param1__avg']
wkt__distance_lt=(origin, Distance(m=DEFAULT_PLACEMENT_POINT_UPDATE_RADIUS))).aggregate(Avg('param1'))
if bargains:
point.property_price_bargains = bargains['param1__avg']
offers_estate = models.OtherObjects.objects.filter(
group__name="offers_estate",
wkt__distance_lt=(origin, Distance(m=DEFAULT_PLACEMENT_POINT_UPDATE_RADIUS))).aggregate(
param1__avg=Avg('param1'), param3__avg=Avg('param3'))
if offers_estate:
point.property_price_offers = offers_estate['param1__avg']
point.property_mean_floor = offers_estate['param3__avg']
point.property_era = models.OtherObjects.objects.filter(
group__name="offers_estate").values('param2').annotate(cnt=Count('param2')).order_by('-cnt').first()[
'param2']
point.flats_cnt = models.OtherObjects.objects.filter(
offers_estate = models.OtherObjects.objects.filter(
group__name="offers_estate").values('param2').annotate(cnt=Count('param2')).order_by('-cnt').first()
if offers_estate:
point.property_era = offers_estate['param2']
flats_cnt = models.OtherObjects.objects.filter(
group__name="flats_cnt",
wkt__distance_lt=(origin, Distance(m=DEFAULT_PLACEMENT_POINT_UPDATE_RADIUS))).aggregate(
param1__sum=Sum('param1'))['param1__sum']
param1__sum=Sum('param1'))
if flats_cnt:
point.flats_cnt = flats_cnt['param1__sum']
popul_home_job = models.OtherObjects.objects.filter(
group__name="popul_home_job",
wkt__distance_lt=(origin, Distance(m=DEFAULT_PLACEMENT_POINT_UPDATE_RADIUS))).aggregate(
param1__sum=Sum('param1'), param3__sum=Sum('param3'))
if popul_home_job:
point.popul_home = popul_home_job['param1__sum']
point.popul_job = popul_home_job['param3__sum']
yndx_food_cnt_amt = models.OtherObjects.objects.filter(
group__name="yndx_food_cnt_amt",
wkt__distance_lt=(origin, Distance(m=DEFAULT_PLACEMENT_POINT_UPDATE_RADIUS))).aggregate(
param1__sum=Sum('param1'), param3__sum=Sum('param3'))
if yndx_food_cnt_amt:
point.yndxfood_sum = yndx_food_cnt_amt['param1__sum']
point.yndxfood_cnt = yndx_food_cnt_amt['param3__sum']
point.school_cnt = models.OtherObjects.objects.filter(
@ -236,10 +245,12 @@ class PointService:
point.tc_cnt = models.OtherObjects.objects.filter(
group__name="TC",
wkt__distance_lt=(origin, Distance(m=DEFAULT_PLACEMENT_POINT_UPDATE_RADIUS))).count()
point.business_activity = models.OtherObjects.objects.filter(
business_activity = models.OtherObjects.objects.filter(
group__name="business_activity",
wkt__distance_lt=(origin, Distance(m=DEFAULT_PLACEMENT_POINT_UPDATE_RADIUS))).aggregate(
param1__sum=Sum('param1'))['param1__sum']
param1__sum=Sum('param1'))
if business_activity:
point.business_activity = business_activity['param1__sum']
point.age_day = AGE_DAY_LIMIT
placement_point = models.PlacementPoint.objects.annotate(
dist=Dist('geometry', origin)).order_by('dist')
@ -250,20 +261,19 @@ class PointService:
for group in groups:
self.calculate_dist_for_group(point, group, instance_type=models.PrePlacementPointPVZDistance)
change_status(
f'Шаг 2. Обогащено {points.filter(matching_status=MatchingStatus.Matched.name).count()} из {points.count()}',
f'Шаг 2 из 3 (Обогащение точек). Обработано {_i} из {points.count()}',
task_name)
run_psql_command()
@staticmethod
def calculate_dist_for_group(point, group, instance_type=models.PlacementPointPVZDistance):
post_object = models.Post_and_pvz.objects.filter(group__name=group.name).annotate(
post_object = models.Post_and_pvz.objects.filter(group__id=group.id).annotate(
distance=Dist("wkt", point.geometry)).order_by('distance').first()
d = instance_type.objects.filter(placement_point=point,
pvz_postamates_group=group).first()
if post_object:
if d:
if d.dist > post_object.distance.m:
d.dist = post_object.distance.m
d.save()
else:

@ -56,7 +56,7 @@ def raschet(table_name='service_placementpoint', need_time=True, task_name=STATU
models.RaschetObjects.objects.all().delete()
models.RaschetGroups.objects.all().delete()
# Запуск ML
change_status('Запуск ML', task_name=task_name)
change_status('Шаг 3 из 3 (Прогноз трафика в точках).', task_name=task_name)
log_to_telegram(f'{table_name} start raschet')
try:
log_to_telegram('try connect to db')
@ -87,6 +87,7 @@ def raschet(table_name='service_placementpoint', need_time=True, task_name=STATU
# Записи для обучения
pts_trn = pts.loc[pts.sample_trn == True].reset_index(drop=True)
pts_trn = pts_trn.sort_values('address').reset_index(drop=True)
pts_trn = gpd.GeoDataFrame(pts_trn, geometry='geometry', crs='epsg:32637')
pts_target = pts_trn[['geometry']]
pts_target['cnt'] = 1
@ -116,7 +117,7 @@ def raschet(table_name='service_placementpoint', need_time=True, task_name=STATU
# status.status = 'Записи для инференса'
# status.save()
change_status('Записи для инференса', task_name=task_name)
#change_status('Записи для инференса', task_name=task_name)
# Записи для инференса
if table_name == 'service_placementpoint':
pts_inf = pts.loc[(pts.status == 'Pending') |
@ -166,7 +167,7 @@ def raschet(table_name='service_placementpoint', need_time=True, task_name=STATU
pts_inf['age_day'] = 240
X_inf = pts_inf[feats]
seeds = [3, 99, 87, 21, 15]
seeds = [99, 87, 21, 15]
# Обучение, инференс
r2_scores = []
@ -174,12 +175,12 @@ def raschet(table_name='service_placementpoint', need_time=True, task_name=STATU
y_infers = []
# status.status = 'Обучение inference 0%'
# status.save()
change_status('Обучение inference 0%', task_name=task_name)
#change_status('Обучение inference 0%', task_name=task_name)
for i in seeds:
# status.status = 'Обучение inference: ' + str(int((seeds.index(i) + 1) / len(seeds) * 100)) + '%'
# status.save()
change_status(f'Обучение inference: {str(int((seeds.index(i) + 1) / len(seeds) * 100))}%',
task_name=task_name)
#change_status(f'Обучение inference: {str(int((seeds.index(i) + 1) / len(seeds) * 100))}%',
#task_name=task_name)
x_trn, x_test, y_trn, y_test = ms.train_test_split(X_trn, Y_trn, test_size=0.2, random_state=i)
model = catboost.CatBoostRegressor(cat_features=['property_era'], random_state=i)
model.fit(x_trn, y_trn, verbose=False)
@ -189,9 +190,9 @@ def raschet(table_name='service_placementpoint', need_time=True, task_name=STATU
r2_scores.append(r2_score)
mapes.append(mape)
y_infers.append(model.predict(X_inf.drop(columns=['id'])))
change_status('Обучение inference 100%', task_name=task_name)
#change_status('Обучение inference 100%', task_name=task_name)
# status.status = 'Обучение inference 100%'
current_pred = sum(y_infers) / 5
current_pred = sum(y_infers) / 4
# расчет шапов
explainer = shap.TreeExplainer(model)
@ -254,7 +255,7 @@ def raschet(table_name='service_placementpoint', need_time=True, task_name=STATU
# status.status = 'Перерасчет ML: 50%'
# status.save()
change_status('Перерасчет ML: 50%', task_name=task_name)
#change_status('Перерасчет ML: 50%', task_name=task_name)
# Загрузка в базу обновленных значений
try:
log_to_telegram('Подключение к базе данных 2')
@ -428,7 +429,7 @@ def load_post_and_pvz(obj_id: int):
for _j, point in enumerate(points):
status.status = "Подсчет расстояний: " + str(int((num_points * _i + _j) / total * 100)) + "%"
status.save()
post_object = models.Post_and_pvz.objects.filter(group__name=group.name).annotate(
post_object = models.Post_and_pvz.objects.filter(group__id=group.id).annotate(
distance=Distance("wkt", point.geometry)).order_by('distance').first()
d = models.PlacementPointPVZDistance.objects.filter(placement_point=point,
pvz_postamates_group=group).first()

@ -48,7 +48,7 @@ def run_psql_command():
)
try:
cursor = connection.cursor()
command = "REFRESH MATERIALIZED VIEW public.points_with_dist;REFRESH MATERIALIZED VIEW public.prepoints_with_dist;"
command = "CALL public.pivot_dist();CALL public.prepivot_dist();REFRESH MATERIALIZED VIEW public.points_with_dist;REFRESH MATERIALIZED VIEW public.prepoints_with_dist;"
cursor.execute(command)
connection.commit()
except psycopg2.Error as e:

@ -7,7 +7,6 @@ from django.http import JsonResponse
from rest_framework.decorators import action
from rest_framework.decorators import api_view
from rest_framework.decorators import permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ReadOnlyModelViewSet
@ -288,9 +287,10 @@ class PlacementPointViewSet(ReadOnlyModelViewSet):
@action(detail=False, methods=['put'])
def update_fact(self, request):
point_id = request.GET.get('postamat_id')
fact = request.GET.get('fact')
if not point_id or not fact or not fact.isdigit():
fact_ = request.GET.get('fact')
if not point_id or not fact_ or not fact_.isdigit():
return Response(status=HTTPStatus.BAD_REQUEST)
fact = float(fact_) if fact_ else None
qs = models.PlacementPoint.objects.filter(postamat_id=point_id)
if not qs:
return Response(status=HTTPStatus.NOT_FOUND)
@ -512,10 +512,12 @@ def upload_houses(request):
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@permission_classes([UserPermission])
def get_current_user(request):
kk_profile = request.auth
kk_roles = kk_profile.get('realm_access', {}).get('roles', [])
return JsonResponse(
{'groups': [gr.name for gr in request.user.groups.all()]},
{'groups': kk_roles, 'username': kk_profile.get('preferred_username')},
)

Loading…
Cancel
Save