Testys commited on
Commit
d6866b9
1 Parent(s): e5624c5

Adding migrations from alembic

Browse files
Dockerfile CHANGED
@@ -1,6 +1,12 @@
1
  # Use a lightweight Python image
2
  FROM python:3.10.12
3
 
 
 
 
 
 
 
4
  # Create a non-root user
5
  RUN useradd -m -u 1000 user
6
 
@@ -15,13 +21,16 @@ RUN pip install --no-cache-dir --upgrade -r requirements.txt
15
  COPY . .
16
 
17
  # Create and set permissions for the cache directory
18
- RUN mkdir /.cache && chown -R user:user /.cache && chmod -R u+w,go-w /.cache
 
 
19
 
20
- # Set up the environment variables for Alembic
21
  ENV ALEMBIC_CONFIG=alembic.ini
 
22
 
23
  # Switch to the non-root user
24
  USER user
25
 
26
  # Run the application, including the migration step
27
- CMD ["bash", "-c", "alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 7860"]
 
1
  # Use a lightweight Python image
2
  FROM python:3.10.12
3
 
4
+ # Install system dependencies
5
+ RUN apt-get update && apt-get install -y \
6
+ libgl1-mesa-glx \
7
+ libglib2.0-0 \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
  # Create a non-root user
11
  RUN useradd -m -u 1000 user
12
 
 
21
  COPY . .
22
 
23
  # Create and set permissions for the cache directory
24
+ RUN mkdir -p /.cache /app/.cache && \
25
+ chown -R user:user /.cache /app/.cache && \
26
+ chmod -R u+w,go-w /.cache /app/.cache
27
 
28
+ # Set up the environment variables
29
  ENV ALEMBIC_CONFIG=alembic.ini
30
+ ENV TORCH_HOME=/app/.cache/torch
31
 
32
  # Switch to the non-root user
33
  USER user
34
 
35
  # Run the application, including the migration step
36
+ CMD ["bash", "-c", "alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 7860"]
alembic/versions/6936c1095473_adding_changes_stated_from_app_.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Adding Changes stated from APP developer side
2
+
3
+ Revision ID: 6936c1095473
4
+ Revises: b55dbab76bb4
5
+ Create Date: 2024-08-31 04:14:27.218586
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = '6936c1095473'
16
+ down_revision: Union[str, None] = 'b55dbab76bb4'
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ pass
23
+
24
+
25
+ def downgrade() -> None:
26
+ pass
orders/models.py CHANGED
@@ -1,5 +1,5 @@
1
  # models.py
2
- from sqlalchemy import Column, ForeignKey, Integer, String, Float, DateTime
3
  from sqlalchemy.orm import relationship
4
  from core.database import Base
5
  from datetime import datetime
@@ -26,3 +26,10 @@ class Order(Base):
26
 
27
  user = relationship("User", back_populates="orders")
28
  meal = relationship("Meal", back_populates="orders")
 
 
 
 
 
 
 
 
1
  # models.py
2
+ from sqlalchemy import Column, ForeignKey, Integer, String, Float, DateTime, LargeBinary
3
  from sqlalchemy.orm import relationship
4
  from core.database import Base
5
  from datetime import datetime
 
26
 
27
  user = relationship("User", back_populates="orders")
28
  meal = relationship("Meal", back_populates="orders")
29
+
30
+ class RecommendationModel(Base):
31
+ __tablename__ = "recommendation_models"
32
+
33
+ id = Column(Integer, primary_key=True, index=True)
34
+ model = Column(LargeBinary, nullable=False)
35
+ created_at = Column(DateTime, default=datetime.utcnow)
orders/routes.py CHANGED
@@ -1,9 +1,11 @@
1
- from fastapi import APIRouter, status, Depends, HTTPException
 
2
  from typing import List, Optional
3
  from core.database import get_db
4
  from core.security import get_current_user
5
  from orders.services import create_meal, get_meals, get_meal, update_meal, delete_meal, create_user_order, get_user_orders
6
  from orders.schemas import OrderCreate, OrderBase, Order, MealBase, MealCreate, MealUpdate, Meal
 
7
  from sqlalchemy.orm import Session
8
  from services.recommendation_service import MealRecommender
9
 
@@ -28,7 +30,12 @@ def health_check():
28
 
29
  @meal_router.post("/", response_model=MealBase, status_code=status.HTTP_201_CREATED)
30
  async def meal_create(data: MealCreate, db: Session = Depends(get_db)):
31
- return create_meal(db, data)
 
 
 
 
 
32
 
33
 
34
  @meal_router.get("/", response_model=List[MealBase])
@@ -62,7 +69,11 @@ def meal_delete(meal_id: int, db: Session = Depends(get_db)):
62
 
63
  @order_router.post("/", response_model=OrderBase, status_code=status.HTTP_201_CREATED)
64
  def create_order(order: OrderCreate, current_user: OrderBase = Depends(get_current_user), db: Session = Depends(get_db)):
65
- return create_user_order(db, order, current_user.id)
 
 
 
 
66
 
67
 
68
  @order_router.get("/", response_model=List[OrderBase])
@@ -71,8 +82,15 @@ def get_orders(current_user: OrderBase = Depends(get_current_user), skip: int =
71
 
72
 
73
  @meal_router.get("/recommendations/", response_model=List[MealBase])
74
- async def get_recommendations(current_user: OrderBase = Depends(get_current_user), db: Session = Depends(get_db)):
 
 
 
 
75
  recommender = MealRecommender(db)
76
  recommendations = recommender.get_recommendations(current_user)
 
 
 
 
77
  return recommendations
78
-
 
1
+ from fastapi import APIRouter, status, Depends, BackgroundTasks, HTTPException
2
+ from fastapi.responses import JSONResponse
3
  from typing import List, Optional
4
  from core.database import get_db
5
  from core.security import get_current_user
6
  from orders.services import create_meal, get_meals, get_meal, update_meal, delete_meal, create_user_order, get_user_orders
7
  from orders.schemas import OrderCreate, OrderBase, Order, MealBase, MealCreate, MealUpdate, Meal
8
+ from users.schemas import User
9
  from sqlalchemy.orm import Session
10
  from services.recommendation_service import MealRecommender
11
 
 
30
 
31
  @meal_router.post("/", response_model=MealBase, status_code=status.HTTP_201_CREATED)
32
  async def meal_create(data: MealCreate, db: Session = Depends(get_db)):
33
+ new_meal = await create_meal(db, data)
34
+ id, name = new_meal.id, new_meal.name
35
+ return JSONResponse(
36
+ content={"id": id, "name": name},
37
+ status_code=status.HTTP_200_OK
38
+ )
39
 
40
 
41
  @meal_router.get("/", response_model=List[MealBase])
 
69
 
70
  @order_router.post("/", response_model=OrderBase, status_code=status.HTTP_201_CREATED)
71
  def create_order(order: OrderCreate, current_user: OrderBase = Depends(get_current_user), db: Session = Depends(get_db)):
72
+ new_order = create_user_order(db, order, current_user.id)
73
+ return JSONResponse(
74
+ content={"id": new_order.id, "meal_id": new_order.meal_id, "quantity": new_order.quantity},
75
+ status_code=status.HTTP_200_OK
76
+ )
77
 
78
 
79
  @order_router.get("/", response_model=List[OrderBase])
 
82
 
83
 
84
  @meal_router.get("/recommendations/", response_model=List[MealBase])
85
+ async def get_recommendations(
86
+ background_tasks: BackgroundTasks,
87
+ current_user: User = Depends(get_current_user),
88
+ db: Session = Depends(get_db)
89
+ ):
90
  recommender = MealRecommender(db)
91
  recommendations = recommender.get_recommendations(current_user)
92
+
93
+ # Schedule model retraining in the background
94
+ background_tasks.add_task(recommender.train_model)
95
+
96
  return recommendations
 
orders/schemas.py CHANGED
@@ -30,3 +30,14 @@ class Order(OrderBase):
30
 
31
  class Config:
32
  orm_mode = True
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  class Config:
32
  orm_mode = True
33
+
34
+
35
+ class Recommendation(BaseModel):
36
+ meal_id: int
37
+ name: str
38
+ description: str
39
+ price: float
40
+ score: float
41
+
42
+ class Config:
43
+ orm_mode = True
services/face_match.py CHANGED
@@ -3,6 +3,9 @@ import numpy as np
3
  from sqlalchemy.orm import Session
4
  from users.models import UserEmbeddings
5
  from core.config import get_settings
 
 
 
6
 
7
  settings = get_settings()
8
 
@@ -10,28 +13,59 @@ class FaceMatch:
10
  def __init__(self, db: Session):
11
  self.db = db
12
  self.threshold = settings.FACE_RECOGNITION_THRESHOLD
 
 
13
 
14
  def load_embeddings_from_db(self):
15
  user_embeddings = self.db.query(UserEmbeddings).all()
16
- return {ue.user_id: np.array(ue.embeddings) for ue in user_embeddings}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  def match_faces(self, new_embeddings, saved_embeddings):
 
 
 
19
  new_embeddings = np.array(new_embeddings)
20
- max_similarity = 0
21
- identity = None
22
 
23
  for user_id, stored_embeddings in saved_embeddings.items():
24
  similarity = cosine_similarity(new_embeddings.reshape(1, -1), stored_embeddings.reshape(1, -1))[0][0]
25
- if similarity > max_similarity:
26
- max_similarity = similarity
27
- identity = user_id
28
 
29
- return identity, max_similarity if max_similarity > self.threshold else (None, 0)
 
 
 
 
 
 
 
 
30
 
31
  def new_face_matching(self, new_embeddings):
32
  embeddings_dict = self.load_embeddings_from_db()
33
  if not embeddings_dict:
34
- return {'status': 'Error', 'message': 'No embeddings available in the database'}
 
 
 
35
 
36
  identity, similarity = self.match_faces(new_embeddings, embeddings_dict)
37
  if identity:
@@ -43,5 +77,5 @@ class FaceMatch:
43
  }
44
  return {
45
  'status': 'Error',
46
- 'message': 'No matching face found'
47
  }
 
3
  from sqlalchemy.orm import Session
4
  from users.models import UserEmbeddings
5
  from core.config import get_settings
6
+ import logging
7
+
8
+ logger = logging.getLogger(__name__)
9
 
10
  settings = get_settings()
11
 
 
13
  def __init__(self, db: Session):
14
  self.db = db
15
  self.threshold = settings.FACE_RECOGNITION_THRESHOLD
16
+ self.max_matches = 1 # Only allow one match
17
+ self.embedding_shape = None # Will be set when loading embeddings
18
 
19
  def load_embeddings_from_db(self):
20
  user_embeddings = self.db.query(UserEmbeddings).all()
21
+ embeddings_dict = {}
22
+ for ue in user_embeddings:
23
+ embedding = np.array(ue.embeddings)
24
+ if self.embedding_shape is None:
25
+ self.embedding_shape = embedding.shape
26
+ elif embedding.shape != self.embedding_shape:
27
+ logger.warning(f"Inconsistent embedding shape for user {ue.user_id}. Expected {self.embedding_shape}, got {embedding.shape}")
28
+ continue # Skip this embedding
29
+ embeddings_dict[ue.user_id] = embedding
30
+ return embeddings_dict
31
+
32
+ def validate_embedding(self, embedding):
33
+ if self.embedding_shape is None:
34
+ logger.warning("No reference embedding shape available")
35
+ return False
36
+ if np.array(embedding).shape != self.embedding_shape:
37
+ logger.warning(f"Invalid embedding shape. Expected {self.embedding_shape}, got {np.array(embedding).shape}")
38
+ return False
39
+ return True
40
 
41
  def match_faces(self, new_embeddings, saved_embeddings):
42
+ if not self.validate_embedding(new_embeddings):
43
+ return None, 0
44
+
45
  new_embeddings = np.array(new_embeddings)
46
+ similarities = []
 
47
 
48
  for user_id, stored_embeddings in saved_embeddings.items():
49
  similarity = cosine_similarity(new_embeddings.reshape(1, -1), stored_embeddings.reshape(1, -1))[0][0]
50
+ similarities.append((user_id, similarity))
 
 
51
 
52
+ # Sort similarities in descending order
53
+ similarities.sort(key=lambda x: x[1], reverse=True)
54
+
55
+ # Check if the top match exceeds the threshold and if there's a significant gap to the second-best match
56
+ if similarities and similarities[0][1] > self.threshold:
57
+ if len(similarities) == 1 or similarities[0][1] - similarities[1][1] > 0.1:
58
+ return similarities[0]
59
+
60
+ return None, 0
61
 
62
  def new_face_matching(self, new_embeddings):
63
  embeddings_dict = self.load_embeddings_from_db()
64
  if not embeddings_dict:
65
+ return {'status': 'Error', 'message': 'No valid embeddings available in the database'}
66
+
67
+ if not self.validate_embedding(new_embeddings):
68
+ return {'status': 'Error', 'message': 'Invalid embedding shape'}
69
 
70
  identity, similarity = self.match_faces(new_embeddings, embeddings_dict)
71
  if identity:
 
77
  }
78
  return {
79
  'status': 'Error',
80
+ 'message': 'No matching face found or multiple potential matches detected'
81
  }
services/facial_processing.py CHANGED
@@ -22,12 +22,16 @@ class FacialProcessing:
22
  # Detect faces
23
  boxes, _ = self.mtcnn.detect(img)
24
 
25
- if boxes is None:
26
  logger.warning(f"No face detected in image: {image_path}")
27
  return None
28
 
 
 
 
 
29
  # Get the largest face
30
- largest_box = max(boxes, key=lambda box: (box[2] - box[0]) * (box[3] - box[1]))
31
  face = self.mtcnn(img, return_prob=False)
32
 
33
  if face is None:
@@ -35,9 +39,19 @@ class FacialProcessing:
35
  return None
36
 
37
  # Extract embeddings
38
- embeddings = self.resnet(face).detach().cpu().numpy().flatten()
 
39
  return embeddings.tolist()
40
 
41
  except Exception as e:
42
  logger.error(f"An error occurred while extracting embeddings: {e}")
 
 
 
 
 
 
 
 
 
43
  return None
 
22
  # Detect faces
23
  boxes, _ = self.mtcnn.detect(img)
24
 
25
+ if boxes is None or len(boxes) == 0:
26
  logger.warning(f"No face detected in image: {image_path}")
27
  return None
28
 
29
+ if len(boxes) > 1:
30
+ logger.warning(f"Multiple faces detected in image: {image_path}")
31
+ return None
32
+
33
  # Get the largest face
34
+ largest_box = boxes[0]
35
  face = self.mtcnn(img, return_prob=False)
36
 
37
  if face is None:
 
39
  return None
40
 
41
  # Extract embeddings
42
+ with torch.no_grad():
43
+ embeddings = self.resnet(face).cpu().numpy().flatten()
44
  return embeddings.tolist()
45
 
46
  except Exception as e:
47
  logger.error(f"An error occurred while extracting embeddings: {e}")
48
+ return None
49
+
50
+ def preprocess_image(self, image_path):
51
+ try:
52
+ img = Image.open(image_path)
53
+ img = img.convert('RGB')
54
+ return img
55
+ except Exception as e:
56
+ logger.error(f"Error opening image: {e}")
57
  return None
services/recommendation_service.py CHANGED
@@ -1,70 +1,63 @@
1
- # recommender_system.py
2
  import pandas as pd
3
  from surprise import Dataset, Reader, SVD
4
  from surprise.model_selection import train_test_split
5
- import joblib
6
  from datetime import datetime, timedelta
7
- import os
8
  import random
9
  from sqlalchemy.orm import Session
 
10
  from typing import List
11
- from orders.models import Order, Meal
12
  from users.models import User
13
 
14
-
15
  class MealRecommender:
16
  def __init__(self, db: Session):
17
  self.db = db
18
- self.model_path = 'recommendation_model.joblib'
19
- self.last_train_path = 'last_train_time.txt'
20
  self.retrain_interval = timedelta(days=1)
21
  self.algo = self.load_or_train_model()
22
 
23
  def fetch_data(self):
24
- orders = self.db.query(Order).all()
25
- return pd.DataFrame([(order.user_id, order.meal_id, order.quantity) for order in orders],
26
- columns=['user_id', 'meal_id', 'quantity'])
 
 
 
 
 
 
 
 
27
 
28
  def train_model(self):
29
  data = self.fetch_data()
30
  if data.empty:
31
- self.algo = None
32
  return None
33
-
34
  reader = Reader(rating_scale=(1, 5))
35
  dataset = Dataset.load_from_df(data[['user_id', 'meal_id', 'quantity']], reader)
36
-
37
  trainset = dataset.build_full_trainset()
38
  algo = SVD()
39
  algo.fit(trainset)
40
-
41
- joblib.dump(algo, self.model_path)
42
- self._update_last_train_time()
 
 
 
 
43
  return algo
44
 
45
  def load_or_train_model(self):
46
- try:
47
- if self._should_retrain():
48
- return self.train_model()
49
- return joblib.load(self.model_path)
50
- except FileNotFoundError:
51
- return self.train_model()
52
-
53
- def _should_retrain(self):
54
- if not os.path.exists(self.last_train_path):
55
- return True
56
- with open(self.last_train_path, 'r') as f:
57
- last_train_time = datetime.fromisoformat(f.read().strip())
58
- return datetime.now() - last_train_time > self.retrain_interval
59
 
60
- def _update_last_train_time(self):
61
- with open(self.last_train_path, 'w') as f:
62
- f.write(datetime.now().isoformat())
 
63
 
64
  def get_recommendations(self, user: User):
65
- if self._should_retrain():
66
- self.algo = self.train_model()
67
-
68
  if self.algo is None:
69
  return self.get_random_recommendations()
70
 
 
 
1
  import pandas as pd
2
  from surprise import Dataset, Reader, SVD
3
  from surprise.model_selection import train_test_split
 
4
  from datetime import datetime, timedelta
5
+ import pickle
6
  import random
7
  from sqlalchemy.orm import Session
8
+ from sqlalchemy import func
9
  from typing import List
10
+ from orders.models import Order, Meal, RecommendationModel
11
  from users.models import User
12
 
 
13
  class MealRecommender:
14
  def __init__(self, db: Session):
15
  self.db = db
 
 
16
  self.retrain_interval = timedelta(days=1)
17
  self.algo = self.load_or_train_model()
18
 
19
  def fetch_data(self):
20
+ # Fetch data in batches to handle large datasets
21
+ batch_size = 1000
22
+ offset = 0
23
+ data = []
24
+ while True:
25
+ batch = self.db.query(Order.user_id, Order.meal_id, Order.quantity).offset(offset).limit(batch_size).all()
26
+ if not batch:
27
+ break
28
+ data.extend(batch)
29
+ offset += batch_size
30
+ return pd.DataFrame(data, columns=['user_id', 'meal_id', 'quantity'])
31
 
32
  def train_model(self):
33
  data = self.fetch_data()
34
  if data.empty:
 
35
  return None
36
+
37
  reader = Reader(rating_scale=(1, 5))
38
  dataset = Dataset.load_from_df(data[['user_id', 'meal_id', 'quantity']], reader)
39
+
40
  trainset = dataset.build_full_trainset()
41
  algo = SVD()
42
  algo.fit(trainset)
43
+
44
+ # Save model to database
45
+ model_binary = pickle.dumps(algo)
46
+ model_record = RecommendationModel(model=model_binary, created_at=datetime.now())
47
+ self.db.add(model_record)
48
+ self.db.commit()
49
+
50
  return algo
51
 
52
  def load_or_train_model(self):
53
+ latest_model = self.db.query(RecommendationModel).order_by(RecommendationModel.created_at.desc()).first()
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
+ if latest_model and datetime.now() - latest_model.created_at <= self.retrain_interval:
56
+ return pickle.loads(latest_model.model)
57
+ else:
58
+ return self.train_model()
59
 
60
  def get_recommendations(self, user: User):
 
 
 
61
  if self.algo is None:
62
  return self.get_random_recommendations()
63
 
users/models.py CHANGED
@@ -8,7 +8,6 @@ class User(Base):
8
 
9
  id = Column(Integer, primary_key=True, index=True)
10
  email = Column(String, unique=True, index=True, nullable=False)
11
- username = Column(String, unique=True, index=True, nullable=False)
12
  password = Column(String, nullable=False)
13
  first_name = Column(String, nullable=True)
14
  last_name = Column(String, nullable=True)
 
8
 
9
  id = Column(Integer, primary_key=True, index=True)
10
  email = Column(String, unique=True, index=True, nullable=False)
 
11
  password = Column(String, nullable=False)
12
  first_name = Column(String, nullable=True)
13
  last_name = Column(String, nullable=True)
users/routes.py CHANGED
@@ -25,8 +25,18 @@ router = APIRouter(
25
  @router.post("/", status_code=status.HTTP_201_CREATED, response_model=UserBase)
26
  async def create_user(data: UserCreate, db: Session = Depends(get_db)):
27
  new_user = await create_user_account(data, db)
28
- return new_user
29
-
 
 
 
 
 
 
 
 
 
 
30
  @router.get("/me/", response_model=UserBase)
31
  async def read_users_me(current_user: User = Depends(get_current_user)):
32
  return current_user
 
25
  @router.post("/", status_code=status.HTTP_201_CREATED, response_model=UserBase)
26
  async def create_user(data: UserCreate, db: Session = Depends(get_db)):
27
  new_user = await create_user_account(data, db)
28
+ access_token_expires = timedelta(minutes=int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30")))
29
+ payload = {"id": new_user.id, "sub": new_user.email}
30
+ access_token = await create_access_token(data=payload, expiry=access_token_expires)
31
+ refresh_token = await create_refresh_token(data=payload)
32
+
33
+ return JSONResponse(content={
34
+ "access_token": access_token,
35
+ "refresh_token": refresh_token,
36
+ "token_type": "Bearer",
37
+ "expires_in": access_token_expires.seconds
38
+ }, status_code=status.HTTP_200_OK)
39
+
40
  @router.get("/me/", response_model=UserBase)
41
  async def read_users_me(current_user: User = Depends(get_current_user)):
42
  return current_user
users/schemas.py CHANGED
@@ -3,7 +3,6 @@ from typing import Optional, List
3
  from datetime import datetime
4
 
5
  class UserBase(BaseModel):
6
- username: str = Field(..., min_length=3, max_length=50)
7
  first_name: str = Field(..., min_length=1, max_length=50)
8
  last_name: str = Field(..., min_length=1, max_length=50)
9
  email: EmailStr
@@ -14,7 +13,6 @@ class UserCreate(UserBase):
14
  password: str = Field(..., min_length=8)
15
 
16
  class UserUpdate(BaseModel):
17
- username: Optional[str] = Field(None, min_length=3, max_length=50)
18
  first_name: Optional[str] = Field(None, min_length=1, max_length=50)
19
  last_name: Optional[str] = Field(None, min_length=1, max_length=50)
20
  email: Optional[EmailStr] = None
 
3
  from datetime import datetime
4
 
5
  class UserBase(BaseModel):
 
6
  first_name: str = Field(..., min_length=1, max_length=50)
7
  last_name: str = Field(..., min_length=1, max_length=50)
8
  email: EmailStr
 
13
  password: str = Field(..., min_length=8)
14
 
15
  class UserUpdate(BaseModel):
 
16
  first_name: Optional[str] = Field(None, min_length=1, max_length=50)
17
  last_name: Optional[str] = Field(None, min_length=1, max_length=50)
18
  email: Optional[EmailStr] = None
users/services.py CHANGED
@@ -12,7 +12,6 @@ async def create_user_account(data: UserCreate, db: Session):
12
 
13
  new_user = User(
14
  email=data.email,
15
- username=data.username,
16
  first_name=data.first_name,
17
  last_name=data.last_name,
18
  age=data.age,
 
12
 
13
  new_user = User(
14
  email=data.email,
 
15
  first_name=data.first_name,
16
  last_name=data.last_name,
17
  age=data.age,