Compare commits

..

12 commits
0.0.0 ... main

11 changed files with 427 additions and 114 deletions

3
.gitignore vendored
View file

@ -1,2 +1,3 @@
data data
src/static/uploads/* venv
src/static/uploads

View file

@ -1 +1,31 @@
Minimal dating website writen in python # Dating-Website
## Description
Minimal dating website made in python.
It uses flask to render the HTML, saves the data in a MySQL 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.
## TODO
- making the website responsive
- adding search features
- likes and dislikes
- cool css
- a grid with all of the users on the index page (where the search will also be)
- security audits
- maybe more stuff later...
## 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
#### Docker/Podman
`docker compose up -d` / `podman-compose up -d`
### python enviornment
`python -m venv venv`
### Install the dependencies
`pip install -r requirements.txxt`
### Run the program
`pyhton src/main.py`

View file

@ -1,14 +1,15 @@
from flask import Flask, render_template, request, redirect, url_for, flash, session from flask import Flask, render_template, request, redirect, url_for, flash, session
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import text
from datetime import date from datetime import date
import gnupg import gnupg
import secrets import secrets
import os import os
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
# defines where the upload folder is and creates it BASE_DIR = os.path.dirname(os.path.abspath(__file__)) # sets the base dir as the diretory where the python file is
UPLOAD_FOLDER = "static/uploads" UPLOAD_FOLDER = os.path.join(BASE_DIR, "static", "uploads") # joins the directories
os.makedirs(UPLOAD_FOLDER, exist_ok=True) os.makedirs(UPLOAD_FOLDER, exist_ok=True) # creates the uploads directorie
# configures the app # configures the app
app = Flask(__name__) # creates de app app = Flask(__name__) # creates de app
@ -78,26 +79,22 @@ def calculate_age(dob: date) -> int:
# saves files to the upload folder and returns their URL # saves files to the upload folder and returns their URL
def save_files(username: str, profile_file, pictures_files): 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) user_folder = os.path.join(app.config['UPLOAD_FOLDER'], username)
os.makedirs(user_folder, exist_ok=True) os.makedirs(user_folder, exist_ok=True)
# prevents unsafe characters to be used in the filename
profile_filename = secure_filename(profile_file.filename) profile_filename = secure_filename(profile_file.filename)
profile_path = os.path.join(user_folder, profile_filename) profile_path = os.path.join(user_folder, profile_filename)
# saves the profile picture to the path
profile_file.save(profile_path) profile_file.save(profile_path)
profile_url = f"/{profile_path.replace(os.sep, '/')}"
# saves all of the other pictures profile_url = f"/static/uploads/{username}/{profile_filename}"
pictures_urls = [] pictures_urls = []
for pic in pictures_files: for pic in pictures_files:
if pic.filename: if pic.filename:
filename = secure_filename(pic.filename) filename = secure_filename(pic.filename)
path = os.path.join(user_folder, filename) path = os.path.join(user_folder, filename)
pic.save(path) pic.save(path)
pictures_urls.append(f"/{path.replace(os.sep, '/')}") pictures_urls.append(f"/static/uploads/{username}/{filename}")
return profile_url, pictures_urls return profile_url, pictures_urls
@ -119,8 +116,58 @@ def pgp_encrypt_and_import(pgp_key: str, message: str):
@app.route("/") @app.route("/")
def home(): def home():
return render_template("index.html") query = User.query
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()]
for like in likes_list:
query = query.filter(
text(f"JSON_CONTAINS(likes, '\"{like}\"')")
)
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"]) @app.route("/register", methods=["GET", "POST"])
def register(): def register():
@ -128,9 +175,39 @@ def register():
# collect data to a dictionary # collect data to a dictionary
data = {key: request.form.get(key) for key in [ data = {key: request.form.get(key) for key in [
"username","pgp","firstname","lastname","sex","date_of_birth","country","xmpp", "username","pgp","firstname","lastname","sex","date_of_birth","country","xmpp",
"email","phone","city","height","weight","race","prefered_age_range" "email","phone","city","height","weight","race"
]} ]}
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
required_fields = ["username","pgp","firstname","lastname","sex","date_of_birth","country","xmpp"] required_fields = ["username","pgp","firstname","lastname","sex","date_of_birth","country","xmpp"]
if not all(data[f] for f in required_fields): if not all(data[f] for f in required_fields):
@ -169,19 +246,30 @@ def register():
# creates a random string # creates a random string
random_string = secrets.token_hex(16) random_string = secrets.token_hex(16)
# uses the string to create the message that wll be encrypted # uses the string to create the message that wll be encrypted
challenge_phrase = f"this is the unencrypted string: {random_string}" challenge_phrase = f"this is the unencrypted string: {random_string}"
# encrypts message # encrypts message
fingerprint, encrypted_msg = pgp_encrypt_and_import(data["pgp"], challenge_phrase) fingerprint, encrypted_msg = pgp_encrypt_and_import(data["pgp"], challenge_phrase)
# checks fingerprint # checks fingerprint
if not fingerprint or not encrypted_msg: if not fingerprint or not encrypted_msg:
flash("Invalid PGP key or encryption failed.") flash("Invalid PGP key or encryption failed.")
return redirect(url_for("register")) return redirect(url_for("register"))
print(fingerprint)
# creates a temporary session used to verify the user # creates a temporary session used to verify the user
session["pending_user"] = {**data, "profile_url": profile_url, "pictures_urls": pictures_urls} session["pending_user"] = {
session["pgp_expected_phrase"] = challenge_phrase **data,
"profile_url": profile_url,
"pictures_urls": pictures_urls,
"fingerprint": fingerprint
}
session['pgp_expected_phrase'] = challenge_phrase
# renders the verification page # renders the verification page
return render_template("verify.html", encrypted_message=encrypted_msg) return render_template("verify.html", encrypted_message=encrypted_msg)
@ -191,18 +279,23 @@ def register():
@app.route("/verify", methods=["POST"]) @app.route("/verify", methods=["POST"])
def verify(): def verify():
# retrieve the phrase from the session
expected_phrase = session.get("pgp_expected_phrase")
# retrieve user data from the session # retrieve user data from the session
data = session.get("pending_user") data = session.get("pending_user")
fingerprint = data.get("fingerprint")
# retrieve the phrase from the session
expected_phrase = session.get("pgp_expected_phrase")
# check to see if data exists # check to see if data exists
if not data or not expected_phrase: if not data or not expected_phrase:
flash("Session expired.") flash("Session expired.")
return redirect(url_for("register")) return redirect(url_for("register"))
# get the decrypted message # get the decrypted message from form
submitted = request.form.get("decrypted_message") submitted = request.form.get("decrypted_message")
# check to see if submission was empty # check to see if submission was empty
if not submitted: if not submitted:
flash("You must paste the decrypted message.") flash("You must paste the decrypted message.")
@ -234,6 +327,8 @@ def verify():
height=float(data["height"]) if data.get("height") else None, height=float(data["height"]) if data.get("height") else None,
weight=int(data["weight"]) if data.get("weight") else None, weight=int(data["weight"]) if data.get("weight") else None,
race=data.get("race") or 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, prefered_age_range=data.get("prefered_age_range") or None,
is_verified=True is_verified=True
) )
@ -244,9 +339,9 @@ def verify():
# creates login session # creates login session
session['user_id'] = new_user.id session['user_id'] = new_user.id
session['username'] = new_user.username session['username'] = new_user.username
# remove temporary session # remove temporary session
session.pop("pending_user", None) session.pop("pending_user", None)
session.pop("pgp_expected_phrase", None)
flash("PGP verification successful! Account created.") flash("PGP verification successful! Account created.")
return redirect(url_for("home")) return redirect(url_for("home"))

View file

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

View file

@ -1,15 +1,47 @@
{% extends "page.html" %} {% extends "page.html" %}
{% block content %} {% block content %}
<h2>Home</h2> <h2>Discover Users</h2>
<p>Page text</p>
<p>Page text</p> <section>
<p>Page text</p> <h3>Search Users</h3>
<p>Page text</p> <form method="GET" action="{{ url_for('home') }}">
<p>Page text</p> <input type="text" name="country" placeholder="Country" value="{{ request.args.get('country', '') }}">
<p>Page text</p> <input type="text" name="city" placeholder="City" value="{{ request.args.get('city', '') }}">
<p>Page text</p>
<p>Page text</p> <select name="sex">
<p>Page text</p> <option value="">Any Sex</option>
<p>Page text</p> <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>
{% endblock %} {% endblock %}

View file

@ -2,15 +2,34 @@
{% block content %} {% block content %}
<h2>Login</h2> <h2>Login</h2>
<p>Enter your username and PGP public key to receive a challenge.</p> <p>Enter your username and PGP public key to receive a verification challenge.</p>
<form method="POST" action="{{ url_for('login') }}"> <form method="POST" action="{{ url_for('login') }}">
<label>Username:</label><br>
<input type="text" name="username" required><br><br>
<label>PGP Public Key:</label><br> <fieldset>
<textarea name="pgp" rows="8" cols="60" required></textarea><br><br> <legend>Account Verification</legend>
<button type="submit">Send Challenge</button> <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> </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 %} {% endblock %}

View file

@ -19,9 +19,7 @@
</nav> </nav>
<body> <body>
{% block content %}{% endblock %}
{% block content %}{% endblock %}
<footer>Dating Website</footer> <footer>Dating Website</footer>
</body> </body>

View file

@ -5,52 +5,117 @@
<form method="POST" enctype="multipart/form-data"> <form method="POST" enctype="multipart/form-data">
<h3>Identity (required)</h3> <fieldset>
<input type="text" name="username" placeholder="Username" required><br> <legend>Account (required)</legend>
<textarea name="pgp" placeholder="PGP Public Key" required></textarea><br>
<h3>Personal Info (required)</h3> <label for="username">Username</label><br>
<input type="text" name="firstname" placeholder="First Name" required><br> <input type="text" id="username" name="username" required><br><br>
<input type="text" name="lastname" placeholder="Last Name" required><br>
<select name="sex" required> <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>
<option value="">Select Sex</option> <option value="">Select Sex</option>
<option value="male">Male</option> <option value="male">Male</option>
<option value="female">Female</option> <option value="female">Female</option>
</select><br> </select><br><br>
<input type="date" name="date_of_birth" required><br> <label for="date_of_birth">Date of Birth</label><br>
<input type="date" id="date_of_birth" name="date_of_birth" required>
</fieldset>
<h3>Profile Picture (required)</h3> <fieldset>
<input type="file" name="profile_picture" accept="image/*" required><br> <legend>Pictures</legend>
<h3>Other Pictures (optional, multiple)</h3> <label for="profile_picture">Profile Picture (required)</label><br>
<input type="file" name="pictures" accept="image/*" multiple><br> <input type="file" id="profile_picture" name="profile_picture" accept="image/*" required><br><br>
<h3>Location (required)</h3> <label for="pictures">Other Pictures (optional)</label><br>
<select name="country" required> <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>
<option value="">Select Country</option> <option value="">Select Country</option>
{% for c in countries %} {% for c in countries %}
<option value="{{ c }}">{{ c }}</option> <option value="{{ c }}">{{ c }}</option>
{% endfor %} {% endfor %}
</select><br> </select><br><br>
<input type="text" name="city" placeholder="City"><br>
<h3>Physical Attributes (optional)</h3> <label for="city">City</label><br>
<input type="number" step="0.01" name="height" placeholder="Height (m)"><br> <input type="text" id="city" name="city">
<input type="number" name="weight" placeholder="Weight (kg)"><br> </fieldset>
<input type="text" name="race" placeholder="Race"><br>
<h3>Preferences (optional)</h3> <fieldset>
<input type="text" name="prefered_age_range" placeholder="Preferred Age Range (e.g. 20-30)"><br> <legend>Physical Attributes</legend>
<h3>Contacts (required)</h3> <label for="height">Height (meters)</label><br>
<input type="text" name="xmpp" placeholder="XMPP" required><br> <input type="number" step="0.01" id="height" name="height"><br><br>
<input type="email" name="email" placeholder="Email"><br>
<input type="text" name="phone" placeholder="Phone"><br><br>
<button type="submit">Register</button> <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> </form>
{% with messages = get_flashed_messages() %} {% with messages = get_flashed_messages() %}

View file

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