Skip to content

Commit

Permalink
- Added Admin system
Browse files Browse the repository at this point in the history
- Added new HTML templates for Admin
- Added enum UserRole to User model
- Fixed hashing in authorization routers
- Location model changed to be flatter
  • Loading branch information
onstabb committed Nov 27, 2023
1 parent 1fc8891 commit 29d9919
Show file tree
Hide file tree
Showing 37 changed files with 374 additions and 104 deletions.
9 changes: 9 additions & 0 deletions data/templates/admin/displays/geopoint.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% if data | is_iter %}
<ul>
{% for value in data %}
<li>{{ value }}</li>
{% endfor %}
</ul>
{% else %}
{{ "{} {} ".format(data["type"], data["coordinates"]) }}
{% endif %}
16 changes: 16 additions & 0 deletions data/templates/admin/forms/geopoint.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<div class="{% if error %}{{ field.error_class }}{% endif %}">
<div class="input-group">
<span class="input-group-text">Longitude</span>
<input id="{{ field.id + '.longitude' }}" name="{{ field.id + '.longitude' }}"
class="{{ field.class_ }} {% if error %}{{ field.error_class }}{% endif %}"
value="{{ '' if data is none else data['coordinates'][0] }}" {{ field.input_params() | safe }} />
<span class="input-group-text">Latitude</span>
<input id="{{ field.id + '.latitude' }}" name="{{ field.id + '.latitude' }}"
class="{{ field.class_ }} {% if error %}{{ field.error_class }}{% endif %}"
value="{{ '' if data is none else data['coordinates'][1] }}" {{ field.input_params() | safe }} />
</div>
{% if field.help_text %}
<small class="form-hint">{{ field.help_text }}</small>
{% endif %}
</div>
{% include "forms/_error.html" %}
4 changes: 4 additions & 0 deletions data/templates/admin/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{% extends "layout.html" %}
{% block content %}
<h2>Welcome to your Mongoengine admin panel</h2>
{% endblock %}
9 changes: 5 additions & 4 deletions requirements-base.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
fastapi~=0.104.1
pydantic~=2.5.1
pydantic~=2.5.2
python-jose~=3.3.0
mongoengine~=0.27.0
Pillow~=10.1.0
Expand All @@ -13,6 +13,7 @@ pymongo~=4.6.0
python-multipart~=0.0.6
pytz~=2023.3
vincenty~=0.1.4
sse-starlette~=1.8.1
boto3~=1.29.4

sse-starlette~=1.8.2
boto3~=1.29.6
starlette-admin~=0.12.2
itsdangerous~=2.1.2
Empty file added src/admin/__init__.py
Empty file.
22 changes: 22 additions & 0 deletions src/admin/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from starlette.middleware import Middleware
from starlette.middleware.sessions import SessionMiddleware
from starlette_admin.contrib.mongoengine import Admin

from admin.auth import AdminCredentialsProvider
from admin.converter import MongoengineModelConverter
from admin.views import EventView, ContactView, UserView
from config import DATA_PATH
from contacts.models import Contact
from events.models import Event
from users.models import User


admin = Admin(
templates_dir=str(DATA_PATH / "templates" / "admin"),
auth_provider=AdminCredentialsProvider(),
middlewares=[Middleware(SessionMiddleware, secret_key="ads1d21m21")]
)

admin.add_view(UserView(User, converter=MongoengineModelConverter(), icon="fa-solid fa-users"))
admin.add_view(ContactView(Contact, converter=MongoengineModelConverter(), icon="fa-solid fa-people-arrows"))
admin.add_view(EventView(Event, converter=MongoengineModelConverter(), icon="fa-solid fa-calendar-day"),)
76 changes: 76 additions & 0 deletions src/admin/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from fastapi import Request, Response


from starlette_admin.auth import AuthProvider, AdminUser
from starlette_admin.exceptions import FormValidationError, LoginFailed

from authorization import service
from authorization.phonenumber import validate_mobile_phone_number
from users.enums import UserRole
from users.models import User

users = {
"admin": {
"name": "Admin",
"avatar": "admin.png",
"company_logo_url": "admin.png",
"roles": ["read", "create", "edit", "delete", "action_make_published"],
},
"johndoe": {
"name": "John Doe",
"avatar": None, # user avatar is optional
"roles": ["read", "create", "edit", "action_make_published"],
},
"viewer": {"name": "Viewer", "avatar": "guest.png", "roles": ["read"]},
}


class AdminCredentialsProvider(AuthProvider):

async def login(
self,
username: str,
password: str,
remember_me: bool,
request: Request,
response: Response,
) -> Response:
try:
phone_number = validate_mobile_phone_number(username)
except ValueError:
raise FormValidationError({"username": "Incorrect format"})

user: User | None = service.get_user_by_phone_number(phone_number)
if not user or not user.check_password(password):
raise LoginFailed("Invalid phone number or password")

if user.role not in UserRole.managers():
raise LoginFailed("Permission denied")

if user.banned:
raise LoginFailed("This user is banned")

request.session.update(phone_number=user.phone_number)
return response


async def is_authenticated(self, request: Request) -> bool:
phone_number: str = request.session.get("phone_number")
if not phone_number:
return False

user: User | None = service.get_user_by_phone_number(phone_number)
if not user or user.banned:
return False

request.state.user = user
return True

def get_admin_user(self, request: Request) -> AdminUser:
user: User = request.state.user
photo_url = user.photo_urls[0] if len(user.photo_urls) > 0 else None
return AdminUser(username=user.phone_number, photo_url=photo_url)

async def logout(self, request: Request, response: Response) -> Response:
request.session.clear()
return response
18 changes: 18 additions & 0 deletions src/admin/converter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from mongoengine import PointField as MongoPointField, LazyReferenceField
from starlette_admin import BaseField, StringField
from starlette_admin.contrib.mongoengine.converters import ModelConverter
from starlette_admin.converters import converts

from admin.fields import PointField


class MongoengineModelConverter(ModelConverter):

@converts(MongoPointField)
def conv_point_field(self, *args, **kwargs) -> BaseField:
return PointField(**self._field_common(*args, **kwargs))


@converts(LazyReferenceField)
def conv_lazy_reference_field(self, *args, **kwargs) -> BaseField:
return StringField(**self._field_common(*args, **kwargs))
28 changes: 28 additions & 0 deletions src/admin/fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from dataclasses import dataclass
from typing import Any, Sequence

from starlette.datastructures import FormData
from starlette.requests import Request
from starlette_admin import fields as admin_fields, RequestAction


@dataclass
class PointField(admin_fields.FloatField):
form_template: str = "forms/geopoint.html"
display_template: str = "displays/geopoint.html"

async def parse_form_data(
self, request: Request, form_data: FormData, action: RequestAction
) -> Sequence[float] | None:
try:
longitude = float(form_data.get(self.id + ".longitude"))
latitude = float(form_data.get(self.id + ".latitude"))
except ValueError:
return None

return longitude, latitude

async def serialize_value(
self, request: Request, value: dict, action: RequestAction
) -> Any:
return dict(value)
60 changes: 60 additions & 0 deletions src/admin/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from fastapi import Request

from starlette_admin import PhoneField
from starlette_admin.contrib.mongoengine import ModelView

from users.enums import UserRole


class UserView(ModelView):
fields = [
"id",
PhoneField("phone_number"),
"is_active",
"banned",
"photo_urls",
"profile",
"last_online",
]
exclude_fields_from_edit = ["phone_number", ]
exclude_fields_from_list = ["profile.location", ]

def can_create(self, request: Request) -> bool:
return UserRole.ADMIN == request.state.user.role



class EventView(ModelView):
fields = [
"id",
"title",
"start_at",
"city_id",
"description",
"location",
"image_urls",
]
exclude_fields_from_list = ["location", "image_urls", "description"]




class ContactView(ModelView):
fields = [
"initiator",
"respondent",
"initiator_state",
"respondent_state",
"status",
"messages"
]

exclude_fields_from_list = ["messages"]
exclude_fields_from_edit = ["messages"]

def can_create(self, request: Request) -> bool:
return UserRole.ADMIN == request.state.user.role

def can_edit(self, request: Request) -> bool:
return UserRole.ADMIN == request.state.user.role

3 changes: 3 additions & 0 deletions src/authorization/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@
SMS_CUSTOMER_ID: str = os.getenv("SMS_CUSTOMER_ID")
SMS_API_KEY: str = os.getenv("SMS_API_KEY")
SMS_GENERATED_CODE_LENGTH: int = 5

ADMIN_PHONE_NUMBER: str = os.getenv("ADMIN_PHONE_NUMBER", "+48888888888")
ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD", "d3mf3og2")
18 changes: 11 additions & 7 deletions src/authorization/routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
)
from authorization.smsservice.telesign import TelesignService
from users.models import User
from security import generate_password
from security import generate_password, hash_password


router: APIRouter = APIRouter(tags=['Authorization'])
Expand All @@ -33,12 +33,15 @@ def confirm_sms(data: SmsConfirmationDataIn):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f'SMS-code is invalid')

generated_password: str = generate_password()
user: User | None = service.get_user_by_phone_number(data.phone_number)

user = service.update_user_password(user, generated_password) \
if user else service.create_user(data.phone_number, generated_password)
hashed_password = hash_password(generated_password)

return TokenDataOut(access_token=user.token, new_password=generated_password)
user: User | None = service.get_user_by_phone_number(data.phone_number)
user = (
service.update_user_password(user, hashed_password)
if user else service.create_user(data.phone_number, hashed_password)
)
token, expires_at = user.token
return TokenDataOut(access_token=token, new_password=generated_password, expires_at=expires_at)


@router.post("/login", response_model=TokenDataOut, response_model_exclude_none=True)
Expand All @@ -49,4 +52,5 @@ def login(user_in: UserCredentialsIn):
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid phone number or password",
)

return TokenDataOut(access_token=user.token, new_password=None)
token, expires_at = user.token
return TokenDataOut(access_token=token, new_password=None, expires_at=expires_at)
1 change: 1 addition & 0 deletions src/authorization/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ class SmsConfirmationDataOut(BaseModel):

class TokenDataOut(BaseModel):
access_token: str
expires_at: datetime
new_password: str | None = None
29 changes: 28 additions & 1 deletion src/authorization/service.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
__all__ = ('get_user_by_phone_number', 'create_user', 'update_user_password')
__all__ = ('get_user_by_phone_number', 'create_user', 'update_user_password', "create_admin")

import logging

from authorization import config
from security import hash_password
from users.enums import UserRole
from users.models import User


log = logging.getLogger(__name__)


def get_user_by_phone_number(phone_number: str, **query) -> User | None:
return User.get_one(phone_number=phone_number, **query)

Expand All @@ -17,3 +25,22 @@ def update_user_password(user: User, password: str) -> User:
user.password = password
user.save()
return user


def create_admin() -> User:
password = config.ADMIN_PASSWORD

admin: User = (
get_user_by_phone_number(config.ADMIN_PHONE_NUMBER) or
User(
phone_number=config.ADMIN_PHONE_NUMBER,
password=hash_password(password),
is_active=False,
role=UserRole.ADMIN
)
)

admin.save()

log.info(f"Initialized Admin({admin.phone_number}, {password}), Token: {admin.token[0]}")
return admin
10 changes: 6 additions & 4 deletions src/candidates/service.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from datetime import datetime

from users.enums import UserRole
from users.models import User


Expand All @@ -12,16 +13,17 @@ def get_candidates_for_user(user: User, limit: int = 1) -> list[dict]:
{"profile.gender_preference": {"$eq": user.profile.gender}},
{"profile.gender_preference": {"$eq": None}}
],
"photo_urls": {"$ne": []}
"photo_urls": {"$ne": []},
"role": {"$nin": UserRole.managers()}
}
if user.profile.gender_preference is not None:
match_query["profile.gender"] = user.profile.gender_preference


pipeline = [
{"$geoNear": {
"near": user.profile.location.current_geo_json,
"distanceField": "profile.location.distance",
"near": user.profile.geo_json,
"distanceField": "profile.distance",
"query": match_query,
"spherical": True,
}
Expand Down Expand Up @@ -85,7 +87,7 @@ def get_candidates_for_user(user: User, limit: int = 1) -> list[dict]:
},

{'$unset': ['events', 'contacts']},
{"$sort": {"profile.location.distance": 1, "age_difference": 1, "last_online": 1}, },
{"$sort": {"profile.distance": 1, "age_difference": 1, "last_online": 1}, },
{"$limit": limit},
]

Expand Down
Loading

0 comments on commit 29d9919

Please sign in to comment.