Compare commits

..

No commits in common. "main" and "0.0.0" have entirely different histories.
main ... 0.0.0

17 changed files with 234 additions and 517 deletions

5
.gitignore vendored
View file

@ -1,5 +1,2 @@
venv
src/static/uploads
data
src/lovedb.db
src/__pycache__
src/static/uploads/*

View file

@ -1,19 +0,0 @@
FROM python:3.14.3-alpine3.23
WORKDIR /app
COPY requirements.txt .
RUN apk add gpg
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
EXPOSE 5000
ENV FLASK_APP=src/main.py
ENV FLASK_RUN_HOST=0.0.0.0
ENV FLASK_ENV=production
CMD ["flask", "run"]

View file

@ -1,19 +1 @@
# Dating-Website
## Description
Minimal dating website made in python.
It uses flask to render the HTML, saves the data in a database and also features an authentication method using PGP where the database stores the user's PGP key's fingerprint and uses that to encrypt a message only you decrypt, but successfully decrypting the message you athenticate to your account.
It's also supposed to be very easy to use, currently its still in development but the vision is that you can be enganged on the website right from the start featuring a very powerfull search page and not needing an account to use the website.
This website also does not use JavaScript making it easy to run on any browser.
## Contributing
If you have suggestions, find bugs, or want to provide code, just open an issue before submiting a PR. I will probably not accept a PR unless I see that it's actually somewhat important, exceptions can be made, but its kinda goofy to write the code before submiting an issue.
## Running the program
### python enviornment
`python -m venv venv`
### Install the dependencies
`pip install -r requirements.txxt`
### Run the program
`pyhton src/main.py`
Minimal dating website writen in python

View file

@ -1,14 +1,15 @@
services:
web:
build: .
container_name: dating
ports:
- "5000:5000"
volumes:
- ./src:/app/src
- ./data:/app/data
db:
image: mariadb:latest
container_name: lovedb
restart: always
environment:
- FLASK_APP=src/main.py
- FLASK_RUN_HOST=0.0.0.0
- FLASK_ENV=development
restart: unless-stopped
MARIADB_ROOT_PASSWORD: love
MARIADB_DATABASE: lovedb
MARIADB_USER: love
MARIADB_PASSWORD: love
ports:
- "3309:3306"
volumes:
- ./data:/var/lib/mysql

93
plan.md Normal file
View file

@ -0,0 +1,93 @@
# User System Plan
## 1. Database Schema (`User` model)
- **Identity & Security**
- `username` (unique, required)
- `pgp` (unique, required)
- `is_verified` (boolean)
- **Contacts**
- `xmpp` (unique, required)
- `email` (unique, optional)
- `phone` (unique, optional)
- **Personal Info**
- `firstname` (required)
- `lastname` (required)
- `sex` (`male` / `female`, required)
- `date_of_birth` (required)
- `race` (optional)
- **Profile & Media**
- `profile_picture` (required)
- `pictures` (optional array)
- **Location**
- `country` (required)
- `city` (optional)
- **Physical Attributes**
- `height` (optional float)
- `weight` (optional int)
- **Preferences**
- `prefered_age_range` (optional)
- `likes` (optional array)
- `dislikes` (optional array)
---
## 2. Registration
1. **User fills form**
- All fields except `id`, `is_verified`.
2. **Server receives data**
- Validate required fields and unique constraints.
- Temporarily store as **unverified**.
3. **PGP Verification**
- Server encrypts a message with user's PGP public key.
- Show **validation page** with encrypted message.
- User decrypts message and submits.
- Server validates ownership and sets `is_verified=True`.
4. **Create Profile Page & Redirect**
- Generate user profile page with all info.
- Redirect user to main page or search page.
---
## 3. Search Page
- **Display**: Public user profiles as cards
- Show `profile_picture`, `firstname`, `lastname`, age, `country`, `city`.
- **Filters**:
- All fields **except** `pgp`, `id`, `username`.
- Include boolean checks for presence of `email`, `phone`, `xmpp`.
- **Profile Click**
- Open full profile page with all info, pictures, likes/dislikes.
---
## 4. Login Flow
1. User enters `username` + `PGP key`.
2. Server verifies PGP via challenge.
3. On success:
- User can edit **all fields** execpt `id` and `username`.
- User can change pgp key, new pgp key must be verified.
---
## 5. Software Stack
- **Software used**:
- Flask (backend framework)
- MySQL (Database)
- SQLAlchemy (ORM)
- python-gnupg (PGP validation)
- Flask-WTF (Forms validation)

View file

@ -1,4 +1,5 @@
Flask
Flask-SQLAlchemy
SQLAlchemy
PyMySQL
python-gnupg

Binary file not shown.

Binary file not shown.

View file

@ -1,19 +1,18 @@
from flask import Flask, render_template, request, redirect, url_for, flash, session
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import text
from datetime import date
import gnupg
import secrets
import os
from werkzeug.utils import secure_filename
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) # sets the base dir as the diretory where the python file is
UPLOAD_FOLDER = os.path.join(BASE_DIR, "static", "uploads") # joins the directories
os.makedirs(UPLOAD_FOLDER, exist_ok=True) # creates the uploads directorie
# defines where the upload folder is and creates it
UPLOAD_FOLDER = "static/uploads"
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
# configures the app
app = Flask(__name__) # creates de app
app.config['SQLALCHEMY_DATABASE_URI'] = f"sqlite:///{os.path.join(BASE_DIR, 'lovedb.db')}" # database connection
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://love:love@localhost:3309/lovedb' # database connection
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False # disable track modifications (for better performance)
app.config['SECRET_KEY'] = 'random' # sets the secret key used to generate random numbers
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER # sets the upload folder
@ -67,7 +66,6 @@ class User(db.Model):
prefered_age_range = db.Column(db.String(20), nullable=True)
likes = db.Column(db.JSON, nullable=True)
dislikes = db.Column(db.JSON, nullable=True)
about = db.Column(db.String(4096), nullable=True)
xmpp = db.Column(db.String(128), unique=True, nullable=False)
email = db.Column(db.String(128), unique=True, nullable=True)
phone = db.Column(db.String(20), unique=True, nullable=True)
@ -80,22 +78,26 @@ def calculate_age(dob: date) -> int:
# saves files to the upload folder and returns their URL
def save_files(username: str, profile_file, pictures_files):
# creates a path for the user inside the upload forlder
user_folder = os.path.join(app.config['UPLOAD_FOLDER'], username)
os.makedirs(user_folder, exist_ok=True)
# prevents unsafe characters to be used in the filename
profile_filename = secure_filename(profile_file.filename)
profile_path = os.path.join(user_folder, profile_filename)
# saves the profile picture to the path
profile_file.save(profile_path)
profile_url = f"/{profile_path.replace(os.sep, '/')}"
profile_url = f"/static/uploads/{username}/{profile_filename}"
# saves all of the other pictures
pictures_urls = []
for pic in pictures_files:
if pic.filename:
filename = secure_filename(pic.filename)
path = os.path.join(user_folder, filename)
pic.save(path)
pictures_urls.append(f"/static/uploads/{username}/{filename}")
pictures_urls.append(f"/{path.replace(os.sep, '/')}")
return profile_url, pictures_urls
@ -108,7 +110,7 @@ def pgp_encrypt_and_import(pgp_key: str, message: str):
return None, None
fingerprint = result.fingerprints[0]
# encrypts message to the user's fingerprint
encrypted = gpg.encrypt(message, recipients=[fingerprint], always_trust=True)
encrypted = gpg.encrypt(message, recipients=[fingerprint])
if not encrypted.ok:
return fingerprint, None
return fingerprint, str(encrypted)
@ -117,56 +119,8 @@ def pgp_encrypt_and_import(pgp_key: str, message: str):
@app.route("/")
def home():
query = User.query
return render_template("index.html")
country = request.args.get("country")
city = request.args.get("city")
sex = request.args.get("sex")
age_min = request.args.get("age_min")
age_max = request.args.get("age_max")
race = request.args.get("race")
likes = request.args.get("likes")
dislikes = request.args.get("dislikes")
if country:
query = query.filter(User.country.ilike(f"%{country}%"))
if city:
query = query.filter(User.city.ilike(f"%{city}%"))
if sex:
query = query.filter(User.sex==sex)
if race:
query = query.filter(User.race.ilike(f"%{race}%"))
today = date.today()
if age_min:
try:
min_age = int(age_min)
dob_max = date(today.year - min_age, today.month, today.day)
query = query.filter(User.date_of_birth <= dob_max)
except ValueError:
pass
if age_max:
try:
max_age = int(age_max)
dob_min = date(today.year - max_age - 1, today.month, today.day)
query = query.filter(User.date_of_birth >= dob_min)
except ValueError:
pass
if likes:
likes_list = [x.strip().lower() for x in likes.split(",") if x.strip()]
users = query.all()
users = [u for u in users if u.likes and all(l in u.likes for l in likes_list)]
if dislikes:
dislikes_list = [x.strip().lower() for x in dislikes.split(",") if x.strip()]
for dislike in dislikes_list:
query = query.filter(
text(f"JSON_CONTAINS(dislikes, '\"{dislike}\"')")
)
users = query.all()
return render_template("index.html", users=users, date=date)
@app.route("/register", methods=["GET", "POST"])
def register():
@ -174,39 +128,9 @@ def register():
# collect data to a dictionary
data = {key: request.form.get(key) for key in [
"username","pgp","firstname","lastname","sex","date_of_birth","country","xmpp",
"email","phone","city","height","weight","race"
"email","phone","city","height","weight","race","prefered_age_range"
]}
min_age = request.form.get("preferred_age_min")
max_age = request.form.get("preferred_age_max")
if min_age and max_age:
try:
min_age = int(min_age)
max_age = int(max_age)
if min_age < 18 or max_age < 18:
flash("Minimum age is 18.")
return redirect(url_for("register"))
if min_age > max_age:
flash("Minimum age cannot be greater than maximum age.")
return redirect(url_for("register"))
data["prefered_age_range"] = f"{min_age}-{max_age}"
except ValueError:
flash("Invalid age range.")
return redirect(url_for("register"))
else:
data["prefered_age_range"] = None
likes_raw = request.form.get("likes", "")
dislikes_raw = request.form.get("dislikes", "")
data["likes"] = list(set(x.strip().lower() for x in likes_raw.split(",") if x.strip()))
data["dislikes"] = list(set(x.strip().lower() for x in dislikes_raw.split(",") if x.strip()))
# required fields
required_fields = ["username","pgp","firstname","lastname","sex","date_of_birth","country","xmpp"]
if not all(data[f] for f in required_fields):
@ -245,30 +169,19 @@ def register():
# creates a random string
random_string = secrets.token_hex(16)
# uses the string to create the message that wll be encrypted
challenge_phrase = f"this is the unencrypted string: {random_string}"
# encrypts message
fingerprint, encrypted_msg = pgp_encrypt_and_import(data["pgp"], challenge_phrase)
# checks fingerprint
if not fingerprint or not encrypted_msg:
flash("Invalid PGP key or encryption failed.")
return redirect(url_for("register"))
print(fingerprint)
# creates a temporary session used to verify the user
session["pending_user"] = {
**data,
"profile_url": profile_url,
"pictures_urls": pictures_urls,
"fingerprint": fingerprint
}
session['pgp_expected_phrase'] = challenge_phrase
session["pending_user"] = {**data, "profile_url": profile_url, "pictures_urls": pictures_urls}
session["pgp_expected_phrase"] = challenge_phrase
# renders the verification page
return render_template("verify.html", encrypted_message=encrypted_msg)
@ -278,23 +191,18 @@ def register():
@app.route("/verify", methods=["POST"])
def verify():
# retrieve user data from the session
data = session.get("pending_user")
fingerprint = data.get("fingerprint")
# retrieve the phrase from the session
expected_phrase = session.get("pgp_expected_phrase")
# retrieve user data from the session
data = session.get("pending_user")
# check to see if data exists
if not data or not expected_phrase:
flash("Session expired.")
return redirect(url_for("register"))
# get the decrypted message from form
# get the decrypted message
submitted = request.form.get("decrypted_message")
# check to see if submission was empty
if not submitted:
flash("You must paste the decrypted message.")
@ -326,8 +234,6 @@ def verify():
height=float(data["height"]) if data.get("height") else None,
weight=int(data["weight"]) if data.get("weight") else None,
race=data.get("race") or None,
likes=data.get("likes") or [],
dislikes=data.get("dislikes") or [],
prefered_age_range=data.get("prefered_age_range") or None,
is_verified=True
)
@ -338,9 +244,9 @@ def verify():
# creates login session
session['user_id'] = new_user.id
session['username'] = new_user.username
# remove temporary session
session.pop("pending_user", None)
session.pop("pgp_expected_phrase", None)
flash("PGP verification successful! Account created.")
return redirect(url_for("home"))

View file

@ -1,32 +1,28 @@
@font-face {
font-family: 'font';
src: url('/static/font/font.ttf') format('truetype');
src: url('/font/font.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'font';
src: url('/static/font/font-Bold.ttf') format('truetype');
src: url('/font/font-Bold.ttf') format('truetype');
font-weight: bold;
font-style: normal;
font-display: swap;
}
body {
background: #FFE0F4;
color: #FF00AA;
text-shadow: 0px 0px 5px rgba(255, 0, 170, 0.8);
padding: 10px;
max-width: 900px;
width: 100%;
background: #101210;
color: #e0e0e0;
text-shadow: 2px 2px 5px rgba(0, 0, 0, 0.8);
max-width: 800px;
margin: auto;
font-family: font;
font-weight: normal;
line-height: 1.4rem;
line-height: 1.2rem;
word-wrap: break-word;
font-size: 22px;
}
footer {
@ -35,120 +31,55 @@ footer {
margin-top: auto;
}
main {
}
img {
max-width: 100%;
height: auto;
}
strong, b {
font-weight: bold;
}
section {
margin-top: 32px;
background: #fff;
padding: 10px;
border: medium dashed #FF00AA;
border-radius: 5px;
}
h1 {
color: #FF00AA;
color: #00ff00;
text-decoration: underline yellow;
text-align: center;
}
h2, h3 {
color: #FF00AA;
h2 {
color: #00ff00;
}
h3 {
color: #00ff00;
}
a {
color: #FF699B;
color: #ffff00;
}
a:hover {
color: #ffffff;
}
table {
width: 100%;
border-collapse: collapse;
overflow-x: auto;
display: block;
summary {
color: #008800;
background: #101210;
}
th, td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
details {
background: #222;
}
th {
background-color: #FF00AA;
color: white;
}
tr:nth-child(even) {
background-color: #FFB3DA;
}
@media (max-width: 768px) {
body {
font-size: 20px;
padding: 8px;
}
section {
padding: 8px;
margin-top: 24px;
}
h1 {
font-size: 1.8rem;
}
h2 {
font-size: 1.4rem;
}
h3 {
font-size: 1.2rem;
}
}
@media (max-width: 480px) {
body {
font-size: 18px;
padding: 6px;
line-height: 1.5rem;
}
section {
padding: 8px;
margin-top: 20px;
}
h1 {
font-size: 1.5rem;
}
h2 {
font-size: 1.2rem;
}
h3 {
font-size: 1.1rem;
}
table {
font-size: 14px;
}
th, td {
padding: 6px;
summary:hover {
color: #fff;
cursor: pointer;
}
.service {
padding: 0.5rem;
border: solid thin #ffff00;
}

View file

@ -1,47 +1,15 @@
{% extends "page.html" %}
{% block content %}
<h2>Discover Users</h2>
<section>
<h3>Search Users</h3>
<form method="GET" action="{{ url_for('home') }}">
<input type="text" name="country" placeholder="Country" value="{{ request.args.get('country', '') }}">
<input type="text" name="city" placeholder="City" value="{{ request.args.get('city', '') }}">
<select name="sex">
<option value="">Any Sex</option>
<option value="male" {% if request.args.get('sex')=='male' %}selected{% endif %}>Male</option>
<option value="female" {% if request.args.get('sex')=='female' %}selected{% endif %}>Female</option>
</select>
<input type="number" name="age_min" placeholder="Min Age" min="18" value="{{ request.args.get('age_min', '') }}">
<input type="number" name="age_max" placeholder="Max Age" min="18" value="{{ request.args.get('age_max', '') }}">
<input type="text" name="race" placeholder="Race" value="{{ request.args.get('race', '') }}">
<input type="text" name="likes" placeholder="Likes" value="{{ request.args.get('likes', '') }}">
<input type="text" name="dislikes" placeholder="Dislikes" value="{{ request.args.get('dislikes', '') }}">
<button type="submit">Search</button>
</form>
</section>
<section>
<h3>Users</h3>
{% if users %}
<div>
{% for user in users %}
<div>
<a href="{{ url_for('user_profile', username=user.username) }}">
<img src="{{ user.profile_picture }}" alt="{{ user.username }}" width="100"><br>
Age: {{ (date.today() - user.date_of_birth).days // 365 }}<br>
Country: {{ user.country }}
</a>
</div>
{% endfor %}
</div>
{% else %}
<p>No users found.</p>
{% endif %}
</section>
<h2>Home</h2>
<p>Page text</p>
<p>Page text</p>
<p>Page text</p>
<p>Page text</p>
<p>Page text</p>
<p>Page text</p>
<p>Page text</p>
<p>Page text</p>
<p>Page text</p>
<p>Page text</p>
{% endblock %}

View file

@ -2,34 +2,15 @@
{% block content %}
<h2>Login</h2>
<p>Enter your username and PGP public key to receive a verification challenge.</p>
<p>Enter your username and PGP public key to receive a challenge.</p>
<form method="POST" action="{{ url_for('login') }}">
<label>Username:</label><br>
<input type="text" name="username" required><br><br>
<fieldset>
<legend>Account Verification</legend>
<label>PGP Public Key:</label><br>
<textarea name="pgp" rows="8" cols="60" required></textarea><br><br>
<label for="username">Username</label><br>
<input type="text" id="username" name="username" required><br><br>
<label for="pgp">PGP Public Key</label><br>
<textarea id="pgp" name="pgp" rows="8" required></textarea><br>
<small>Paste your full public key block</small>
</fieldset>
<br>
<button type="submit">Send Challenge</button>
</form>
<!-- Flash messages -->
{% with messages = get_flashed_messages() %}
{% if messages %}
<ul>
{% for message in messages %}
<li style="color:red;">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
{% endblock %}

View file

@ -3,7 +3,6 @@
<head>
<title>Dating Website</title>
<link rel="stylesheet" href="{{ url_for('static', filename='drip.css') }}">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<header><h1>Dating Website</h1></header>
<nav>
@ -20,7 +19,9 @@
</nav>
<body>
{% block content %}{% endblock %}
<footer>Dating Website</footer>
</body>

View file

@ -5,117 +5,52 @@
<form method="POST" enctype="multipart/form-data">
<fieldset>
<legend>Account (required)</legend>
<h3>Identity (required)</h3>
<input type="text" name="username" placeholder="Username" required><br>
<textarea name="pgp" placeholder="PGP Public Key" required></textarea><br>
<label for="username">Username</label><br>
<input type="text" id="username" name="username" required><br><br>
<h3>Personal Info (required)</h3>
<input type="text" name="firstname" placeholder="First Name" required><br>
<input type="text" name="lastname" placeholder="Last Name" required><br>
<label for="pgp">PGP Public Key</label><br>
<textarea id="pgp" name="pgp" rows="6" required></textarea>
</fieldset>
<fieldset>
<legend>Personal Info (required)</legend>
<label for="firstname">First Name</label><br>
<input type="text" id="firstname" name="firstname" required><br><br>
<label for="lastname">Last Name</label><br>
<input type="text" id="lastname" name="lastname" required><br><br>
<label for="sex">Sex</label><br>
<select id="sex" name="sex" required>
<select name="sex" required>
<option value="">Select Sex</option>
<option value="male">Male</option>
<option value="female">Female</option>
</select><br><br>
</select><br>
<label for="date_of_birth">Date of Birth</label><br>
<input type="date" id="date_of_birth" name="date_of_birth" required>
</fieldset>
<input type="date" name="date_of_birth" required><br>
<fieldset>
<legend>Pictures</legend>
<h3>Profile Picture (required)</h3>
<input type="file" name="profile_picture" accept="image/*" required><br>
<label for="profile_picture">Profile Picture (required)</label><br>
<input type="file" id="profile_picture" name="profile_picture" accept="image/*" required><br><br>
<h3>Other Pictures (optional, multiple)</h3>
<input type="file" name="pictures" accept="image/*" multiple><br>
<label for="pictures">Other Pictures (optional)</label><br>
<input type="file" id="pictures" name="pictures" accept="image/*" multiple>
</fieldset>
<fieldset>
<legend>Location</legend>
<label for="country">Country</label><br>
<select id="country" name="country" required>
<h3>Location (required)</h3>
<select name="country" required>
<option value="">Select Country</option>
{% for c in countries %}
<option value="{{ c }}">{{ c }}</option>
{% endfor %}
</select><br><br>
</select><br>
<input type="text" name="city" placeholder="City"><br>
<label for="city">City</label><br>
<input type="text" id="city" name="city">
</fieldset>
<h3>Physical Attributes (optional)</h3>
<input type="number" step="0.01" name="height" placeholder="Height (m)"><br>
<input type="number" name="weight" placeholder="Weight (kg)"><br>
<input type="text" name="race" placeholder="Race"><br>
<fieldset>
<legend>Physical Attributes</legend>
<h3>Preferences (optional)</h3>
<input type="text" name="prefered_age_range" placeholder="Preferred Age Range (e.g. 20-30)"><br>
<label for="height">Height (meters)</label><br>
<input type="number" step="0.01" id="height" name="height"><br><br>
<h3>Contacts (required)</h3>
<input type="text" name="xmpp" placeholder="XMPP" required><br>
<input type="email" name="email" placeholder="Email"><br>
<input type="text" name="phone" placeholder="Phone"><br><br>
<label for="weight">Weight (kg)</label><br>
<input type="number" id="weight" name="weight"><br><br>
<label for="race">Race</label><br>
<select id="race" name="race">
<option value="">Select race</option>
<option value="Latino(a)">Latino</option>
<option value="Asian">Asian</option>
<option value="White">White</option>
<option value="Black">Black</option>
<option value="Brown">Brown</option>
<option value="Native American">Native American</option>
</select>
</fieldset>
<fieldset>
<legend>Preferences</legend>
<label>Preferred Age Range</label><br>
<label for="preferred_age_min">Minimum Age</label><br>
<input type="number" id="preferred_age_min" name="preferred_age_min" min="18" max="120"><br>
<label for="preferred_age_max">Maximum Age</label><br>
<input type="number" id="preferred_age_max" name="preferred_age_max" min="18" max="120"><br>
<label for="likes">Things You Like</label><br>
<input type="text" id="likes" name="likes" placeholder="music, travel, coding"><br>
<small>Separate with commas</small><br><br>
<label for="dislikes">Things You Don't Like</label><br>
<input type="text" id="dislikes" name="dislikes" placeholder="smoking, drama"><br>
<small>Separate with commas</small>
</fieldset>
<fieldset>
<legend>Contacts (required)</legend>
<label for="xmpp">XMPP</label><br>
<input type="text" id="xmpp" name="xmpp" required><br><br>
<label for="email">Email</label><br>
<input type="email" id="email" name="email"><br><br>
<label for="phone">Phone</label><br>
<input type="text" id="phone" name="phone">
</fieldset>
<br>
<button type="submit">Register</button>
</form>
{% with messages = get_flashed_messages() %}

View file

@ -1,97 +1,37 @@
{% extends "page.html" %}
{% block content %}
<section>
<h2>{{ user.firstname }} {{ user.lastname }}</h2>
<img src="{{ user.profile_picture }}" alt="Profile Picture" style="max-width:200px; border-radius:8px;"><br><br>
<p><strong>Age:</strong> {{ (date.today() - user.date_of_birth).days // 365 }}</p>
<p><strong>Sex:</strong> {{ user.sex|capitalize }}</p>
</section>
<img src="{{ user.profile_picture }}" alt="Profile Picture" width="150"><br>
{% if user.pictures %}
<section>
<h3>Gallery</h3>
<h3>Pictures</h3>
{% for pic in user.pictures %}
<img src="{{ pic }}" style="max-width:120px; margin:5px; border-radius:6px;">
<img src="{{ pic }}" width="100">
{% endfor %}
</section>
{% endif %}
<section>
<h3>Personal Info</h3>
<p><strong>Date of Birth:</strong> {{ user.date_of_birth }}</p>
<p><strong>Race:</strong> {{ user.race or 'Not specified' }}</p>
</section>
<p>Sex: {{ user.sex }}</p>
<p>Date of Birth: {{ user.date_of_birth }}</p>
<p>Age: {{ (date.today() - user.date_of_birth).days // 365 }}</p>
<p>Race: {{ user.race or 'Not specified' }}</p>
<section>
<h3>Location</h3>
<p><strong>Country:</strong> {{ user.country }}</p>
<p><strong>City:</strong> {{ user.city or 'Not specified' }}</p>
</section>
<p>Country: {{ user.country }}</p>
<p>City: {{ user.city or 'Not specified' }}</p>
<section>
<h3>Physical Attributes</h3>
<p><strong>Height:</strong>
{% if user.height %}
{{ user.height }} m
{% else %}
Not specified
{% endif %}
</p>
<p>Height: {{ user.height or 'Not specified' }} m</p>
<p>Weight: {{ user.weight or 'Not specified' }} kg</p>
<p><strong>Weight:</strong>
{% if user.weight %}
{{ user.weight }} kg
{% else %}
Not specified
{% endif %}
</p>
</section>
<section>
<h3>Preferences</h3>
<p>Preferred Age Range: {{ user.prefered_age_range or 'Not specified' }}</p>
<p>Likes: {{ user.likes | join(', ') if user.likes else 'Not specified' }}</p>
<p>Dislikes: {{ user.dislikes | join(', ') if user.dislikes else 'Not specified' }}</p>
<p><strong>Preferred Age Range:</strong>
{{ user.prefered_age_range or 'Not specified' }}
</p>
<p><strong>Likes:</strong></p>
{% if user.likes %}
<ul>
{% for like in user.likes %}
<li>{{ like }}</li>
{% endfor %}
</ul>
{% else %}
<p>Not specified</p>
{% endif %}
<p><strong>Dislikes:</strong></p>
{% if user.dislikes %}
<ul>
{% for dislike in user.dislikes %}
<li>{{ dislike }}</li>
{% endfor %}
</ul>
{% else %}
<p>Not specified</p>
{% endif %}
</section>
<section>
<h3>Contacts</h3>
<p><strong>XMPP:</strong> {{ user.xmpp }}</p>
<p><strong>Email:</strong> {{ user.email or 'Not specified' }}</p>
<p><strong>Phone:</strong> {{ user.phone or 'Not specified' }}</p>
</section>
<p>XMPP: {{ user.xmpp }}</p>
<p>Email: {{ user.email or 'Not specified' }}</p>
<p>Phone: {{ user.phone or 'Not specified' }}</p>
{% endblock %}