Compare commits

...
24 Commits
Author SHA1 Message Date
Superredstone 83443e11cc fix: project_list margin 2026-09-16 18:09:10 +02:00
Superredstone c8cf7ea7cd feat(portfolio): add tag search to projects 2026-09-16 17:48:04 +02:00
Superredstone f0888c4168 feat(portfolio): add project search 2026-09-16 17:45:08 +02:00
Superredstone c2e5f9a3a2 feat(portfolio): add project tags 2026-09-16 17:31:04 +02:00
Superredstone 6a4e6ba9c5 feat(admin): improved list display for some models 2026-09-15 19:56:10 +02:00
Superredstone f54c4efb1d feat(portfolio): add company color 2026-09-15 19:50:36 +02:00
Superredstone 02568054f1 break(portfolio): remove redundant uploads folder since media is set 2026-09-15 19:45:27 +02:00
Superredstone 06d759e809 fix: set projects link to target _blank 2026-09-14 21:15:23 +02:00
Superredstone cc76207bb4 fix: move uploads away from static files & fix company logo 2026-09-14 21:12:33 +02:00
Superredstone 1d25e49966 fix: add whitenoise to enable static file serving on docker 2026-09-14 20:14:24 +02:00
Superredstone b10f0c65bb fix: add wholestatic folder 2026-09-14 19:53:12 +02:00
Superredstone 4be478ab33 ci: add collectstatic on container build 2026-09-14 19:46:58 +02:00
Superredstone fca325d2cc feat(settings): wrapped local_settings import between try catch 2026-09-14 19:29:00 +02:00
Patrick Canal 456dfa5793 ci: add docker container push to gh
Updated the Docker CI workflow to include tag support and improved structure.
2026-09-14 19:13:01 +02:00
Patrick Canal e48e524c0a ci: add docker image build 2026-09-14 19:05:54 +02:00
Superredstone 874764c3e3 chore: add dockerization 2026-09-14 19:04:27 +02:00
Superredstone 92c15da936 docs: add missing variables 2026-09-14 17:25:53 +02:00
Superredstone e4383cabc8 feat(portfolio): improve responsiveness 2026-09-14 17:21:02 +02:00
Superredstone 7b3de9c9da feat(base): add admin link to navbar 2026-09-14 17:12:45 +02:00
Superredstone 00f6633ecf feat(portfolio): add email contact 2026-09-14 17:06:01 +02:00
Superredstone 14118cf438 feat(navbar): add project tab 2026-09-10 16:24:19 +02:00
Superredstone cce932d171 chore: fix pre-commit hook 2026-09-10 13:58:49 +02:00
Superredstone 3944c971eb chore: remove __pycache__ 2026-08-30 19:05:13 +02:00
Superredstone bd6a22e814 feat(portfolio): improve landing page 2026-08-28 20:42:48 +02:00
41 changed files with 990 additions and 79 deletions
+13
View File
@@ -0,0 +1,13 @@
.git
.gitignore
__pycache__
*.pyc
*.pyo
*.pyd
.env
venv
.venv
db.sqlite3
staticfiles
media
wholestatic
+43
View File
@@ -0,0 +1,43 @@
name: Build and Push Docker Image
on:
push:
branches: [main]
tags: ["v*"]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+7 -3
View File
@@ -8,7 +8,11 @@ devenv.local.yaml
# direnv # direnv
.direnv .direnv
# pre-commit
.pre-commit-config.yaml
*.sqlite3 *.sqlite3
.ruff_cache
*.pyc
__pycache__/
local_settings.py
uploads/
wholestatic/
+15
View File
@@ -0,0 +1,15 @@
repos:
- repo: https://github.com/djlint/djLint
rev: v1.46.1
hooks:
- id: djlint-reformat-django
language: system
- id: djlint-django
language: system
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.6
hooks:
- id: ruff-check
language: system
- id: ruff-format
language: system
+20
View File
@@ -0,0 +1,20 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["sh", "-c", "python manage.py migrate && python manage.py collectstatic --noinput && gunicorn patrickcanal_it.wsgi:application --bind 0.0.0.0:8000"]
+18
View File
@@ -0,0 +1,18 @@
# patrickcanal.it
## Docker
Build container
`docker build . -t patrickcanal.it`
Run docker run
`docker run -p 8000:8000 patrickcanal.it -v "$(pwd)/patrickcanal.it/local_settings.py:/app/patrickcanal.it/local_settings.py" -v "$(pwd)/patrickcanal.it/db.sqlite:/app/db.sqlite"`
## Required variables
### Portfolio
Email variables will be replaced once `django-post_office` will be updated to support django 6.1 email system
- MAILER_HOST
- EMAIL_CONTACT
- EMAIL_HOST_USER
- EMAIL_HOST_PASSWORD
- EMAIL_PORT
- EMAIL_USE_TLS
+15 -15
View File
@@ -3,11 +3,11 @@
"devenv": { "devenv": {
"locked": { "locked": {
"dir": "src/modules", "dir": "src/modules",
"lastModified": 1786975632, "lastModified": 1789304775,
"narHash": "sha256-3N/pfn4/ADlkTzHJWgClmKD1mTLyVJNFxLbDMMHl/xw=", "narHash": "sha256-WPuZ7LbqlQ8n+kocw8oo4sNvHy//LXBTAHOqRDp5I5c=",
"owner": "cachix", "owner": "cachix",
"repo": "devenv", "repo": "devenv",
"rev": "c972cb46d511e690c942e6ee3309a7e8b9a20475", "rev": "e6f5e1e6cda3a75bad3e0a4af97eb9e70bc9e127",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -55,11 +55,11 @@
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
}, },
"locked": { "locked": {
"lastModified": 1784288435, "lastModified": 1788267358,
"narHash": "sha256-ReRHaLgr/uVqdD8afFSn+myXIfpHeOhP0yYe0TJqAA8=", "narHash": "sha256-nt+lUqYVpc9Y6JeMd2WmXzCDojasdadKo0mWcluvY2Y=",
"owner": "cachix", "owner": "cachix",
"repo": "git-hooks.nix", "repo": "git-hooks.nix",
"rev": "43b3c1ab9d40fb1dbb008f451988a91e375825e9", "rev": "27555e2624241fb116b49095df4caaee85a25691",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -70,11 +70,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1782918843, "lastModified": 1787631388,
"narHash": "sha256-ETYnV9U7Sr+A45dohzZdfCZKOss4qrTkO+wgNZNvEc0=", "narHash": "sha256-vMiXptXarfSdJb1Gkc+FYVOAibuBRj7qxGa8z68q1Uw=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "e8273b29fe1390ec8d4603f2477357555291432e", "rev": "ac6b2166e7a9375683b8e98f860f273222337b16",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -108,11 +108,11 @@
"nixpkgs-src": { "nixpkgs-src": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1786098110, "lastModified": 1787394516,
"narHash": "sha256-shi1tjDhCGGd0kIgVPNY03V8NBWSTzhMSFDb7IqSoec=", "narHash": "sha256-pRGOQSClnXNI2iLUG6DYpsGvYcuw0drOutVZFTJNw90=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "afb4584a80bbf779ce0f691509ff902d188c2b3d", "rev": "c8f90650c15282fa8656a041bfbbd2403997a9a7",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -127,11 +127,11 @@
"nixpkgs-src": "nixpkgs-src" "nixpkgs-src": "nixpkgs-src"
}, },
"locked": { "locked": {
"lastModified": 1786472144, "lastModified": 1787753358,
"narHash": "sha256-W1wLGKZKGtnTWGRFgjbsVQleuO6jgMweaSAbFltLkN0=", "narHash": "sha256-Tl77VbWyAKrOfRNQhL6JbQtb/MLzbYa/1RG1gWWfICk=",
"owner": "cachix", "owner": "cachix",
"repo": "devenv-nixpkgs", "repo": "devenv-nixpkgs",
"rev": "ea21d30f66a051c7cd750692a2af8d56e0ec4bfb", "rev": "256551e45f6303e142ab4a98be1bf243feb77dc0",
"type": "github" "type": "github"
}, },
"original": { "original": {
-11
View File
@@ -10,16 +10,5 @@
enable = true; enable = true;
requirements = ./requirements.txt; requirements = ./requirements.txt;
}; };
};
git-hooks.hooks = {
ruff.enable = true;
djlint = {
enable = true;
name = "djlint";
entry = "djlint --check .";
types = [ "html" ];
};
}; };
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+25 -6
View File
@@ -37,6 +37,8 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'post_office',
'portfolio'
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -47,6 +49,7 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
"whitenoise.middleware.WhiteNoiseMiddleware",
] ]
ROOT_URLCONF = 'patrickcanal_it.urls' ROOT_URLCONF = 'patrickcanal_it.urls'
@@ -113,20 +116,36 @@ USE_I18N = True
USE_TZ = True USE_TZ = True
STATIC_ROOT = BASE_DIR / "wholestatic"
try:
from patrickcanal_it.local_settings import *
except Exception as e:
print(e)
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.1/howto/static-files/ # https://docs.djangoproject.com/en/6.1/howto/static-files/
STATIC_URL = 'static/' STATIC_URL = 'static/'
STATICFILES_DIRS = [ STATICFILES_DIRS = [
BASE_DIR / "static/" BASE_DIR / "static/",
] ]
MEDIA_URL = "/uploads/"
MEDIA_ROOT = BASE_DIR / "uploads/"
# Email # Email
# https://docs.djangoproject.com/en/6.1/topics/email/#topic-email-configuration # https://docs.djangoproject.com/en/6.1/topics/email/#topic-email-configuration
EMAIL_BACKEND = 'post_office.EmailBackend'
# TODO: Replace old mail backend with new once post_office supports it
# MAILERS = {
# 'default': {
# 'BACKEND': 'post_office.EmailBackend',
# },
# 'smtp': {
# 'BACKEND': 'django.core.mail.backends.smtp.EmailBackend',
# 'OPTIONS': {'host': MAILER_HOST, 'port': 587},
# },
# }
# POST_OFFICE = {'DEFAULT_MAILER': 'smtp'}
MAILERS = {
'default': {
'BACKEND': 'django.core.mail.backends.console.EmailBackend',
},
}
+29 -6
View File
@@ -4,15 +4,18 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Patrick Canal developer website">
<title>Patrick Canal</title> <title>Patrick Canal</title>
<link rel="stylesheet" href="{% static 'css/style.css' %}">
<link rel="stylesheet" href="{% static 'tabler/tabler.min.css' %}"> <link rel="stylesheet" href="{% static 'tabler/tabler.min.css' %}">
<link rel="stylesheet" href="{% static 'fontawesome-7/css/all.min.css' %}"> <link rel="stylesheet" href="{% static 'fontawesome-7/css/all.min.css' %}">
<link rel="stylesheet" href="{% static 'css/style.css' %}"> <script src="{% static 'tabler/tabler.min.js' %}"></script>
<script href="{% static 'tabler/tabler.min.css' %}"></script> <script src="{% static 'tailwindcss/tailwindcss.min.js' %}"></script>
<script src="{% static 'htmx/htmx.min.js' %}"></script>
</head> </head>
<body class="d-flex flex-column"> <body class="d-flex flex-column">
<header class="navbar navbar-expand-md d-print-none"> <header class="navbar navbar-expand-md d-print-none">
<div class="container-xl"> <div class="container-xl d-flex justify-content-between">
<button class="navbar-toggler" <button class="navbar-toggler"
type="button" type="button"
data-bs-toggle="collapse" data-bs-toggle="collapse"
@@ -23,18 +26,38 @@
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<ul class="navbar-nav"> <ul class="navbar-nav">
<li class="nav-item active"> <li class="nav-item {% if request.path == "/portfolio/" %}active{% endif %}">
<a class="nav-link" href="{% url 'index' %}"> <a class="nav-link" href="{% url 'index' %}">
<span class="nav-link-icon"> <span class="nav-link-icon">
<i class="fa-solid fa-house"></i> <i class="fa-solid fa-house"></i>
</span> </span>
<span class="nav-link-title">Home</span> <span class="nav-link-title">{% trans "Home" %}</span>
</a>
</li>
<li class="nav-item {% if "/portfolio/projects" in request.path %}active{% endif %}">
<a class="nav-link" href="{% url 'projects' %}">
<span class="nav-link-icon">
<i class="fa-solid fa-folder-open"></i>
</span>
<span class="nav-link-title">{% trans "Projects" %}</span>
</a> </a>
</li> </li>
</ul> </ul>
{% if request.user.is_superuser %}
<ul class="navbar-nav">
<li class="nav-item">
<a href="{% url 'admin:index' %}" class="nav-link">
<span class="nav-link-icon">
<i class="fa-solid fa-screwdriver-wrench"></i>
</span>
<span class="nav-link-title">{% trans "Admin" %}</span>
</a>
</li>
</ul>
{% endif %}
</div> </div>
</header> </header>
<main> <main class="mb-2">
{% block content %} {% block content %}
{% endblock content %} {% endblock content %}
</main> </main>
+8 -2
View File
@@ -15,12 +15,18 @@ Including another URLconf
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import include, path
from portfolio.views import IndexView from patrickcanal_it.views import IndexView
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("", IndexView.as_view(), name="index"), path("", IndexView.as_view(), name="index"),
path("portfolio/", include("portfolio.urls")),
] ]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+7
View File
@@ -0,0 +1,7 @@
from django.shortcuts import redirect
from django.views.generic import View
class IndexView(View):
def get(self, request, *args, **kwargs):
return redirect("portfolio")
Binary file not shown.
Binary file not shown.
+27 -1
View File
@@ -1,2 +1,28 @@
from django.contrib import admin
# Register your models here. from portfolio import models
@admin.register(models.JobExperience)
class JobExperienceAdmin(admin.ModelAdmin):
list_display = ["company", "position", "start", "end"]
@admin.register(models.JobTask)
class JobTaskAdmin(admin.ModelAdmin):
list_display = ["job__company", "description", "job__start", "job__end"]
@admin.register(models.Company)
class CompanyAdmin(admin.ModelAdmin):
list_display = ["name", "location"]
@admin.register(models.Project)
class ProjectAdmin(admin.ModelAdmin):
list_display = ["name", "short_description"]
@admin.register(models.ProjectTag)
class ProjectTagAdmin(admin.ModelAdmin):
list_display = ["name", "color"]
+54
View File
@@ -0,0 +1,54 @@
from django import forms
from django.utils.translation import gettext as _
class TablerEmailInput(forms.EmailInput):
def __init__(self, *args, **kwargs):
attrs = kwargs.setdefault("attrs", {})
attrs.update(
{
"class": "form-control",
"placeholder": "example@mail.com",
"autocomplete": "off",
"minlength": "4",
}
)
super().__init__(*args, **kwargs)
class TablerTextInput(forms.TextInput):
def __init__(self, placeholder=None, *args, **kwargs):
attrs = kwargs.setdefault("attrs", {})
attrs.update(
{
"class": "form-control",
"placeholder": placeholder,
"autocomplete": "off",
"minlength": "4",
}
)
super().__init__(*args, **kwargs)
class TablerTextArea(forms.Textarea):
def __init__(self, placeholder=None, *args, **kwargs):
attrs = kwargs.setdefault("attrs", {})
attrs.update(
{
"class": "form-control",
"placeholder": placeholder,
"autocomplete": "off",
"minlength": "4",
}
)
super().__init__(*args, **kwargs)
class ContactForm(forms.Form):
email = forms.EmailField(max_length=50, min_length=4, widget=TablerEmailInput())
subject = forms.CharField(
max_length=200,
min_length=4,
widget=TablerTextInput(placeholder=_("Your message")),
)
message = forms.CharField(max_length=5000, min_length=10, widget=TablerTextArea())
+41
View File
@@ -0,0 +1,41 @@
# Generated by Django 6.1 on 2026-08-27 14:16
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Company',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Company')),
('location', models.CharField(max_length=255, verbose_name='Location')),
('logo', models.FileField(upload_to='uploads/portfolio/companies_logo/')),
],
),
migrations.CreateModel(
name='JobExperience',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('start', models.DateField(verbose_name='Start date')),
('end', models.DateField(blank=True, null=True, verbose_name='End date')),
('company', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='portfolio.company')),
],
),
migrations.CreateModel(
name='JobTask',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('description', models.TextField(max_length=4192, verbose_name='Description')),
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='portfolio.jobexperience')),
],
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.1 on 2026-08-27 19:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portfolio', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='company',
options={'verbose_name': 'Company', 'verbose_name_plural': 'Companies'},
),
migrations.AddField(
model_name='jobexperience',
name='position',
field=models.CharField(default='CHANGEME', verbose_name='Position'),
preserve_default=False,
),
]
@@ -0,0 +1,28 @@
# Generated by Django 6.1 on 2026-08-30 18:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portfolio', '0002_alter_company_options_jobexperience_position'),
]
operations = [
migrations.CreateModel(
name='Project',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=256, verbose_name='Name')),
('short_description', models.CharField(max_length=512, verbose_name='Short description')),
('url', models.URLField(blank=True, verbose_name='URL')),
],
),
migrations.AlterField(
model_name='jobtask',
name='job',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_tasks', to='portfolio.jobexperience'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.1.1 on 2026-09-15 17:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portfolio', '0003_project_alter_jobtask_job'),
]
operations = [
migrations.AddField(
model_name='company',
name='color',
field=models.CharField(default='000000', max_length=6, verbose_name='Color (hex)'),
),
migrations.AlterField(
model_name='company',
name='logo',
field=models.FileField(upload_to='portfolio/companies_logo/'),
),
]
@@ -0,0 +1,26 @@
# Generated by Django 6.1.1 on 2026-09-15 18:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portfolio', '0004_company_color_alter_company_logo'),
]
operations = [
migrations.CreateModel(
name='ProjectTag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=25, verbose_name='Name')),
('color', models.CharField(default='000000', max_length=6, verbose_name='Color (hex)')),
],
),
migrations.AddField(
model_name='project',
name='tags',
field=models.ManyToManyField(blank=True, related_name='projects', to='portfolio.projecttag', verbose_name='Tags'),
),
]
+91
View File
@@ -1 +1,92 @@
from django.db import models
from django.utils.translation import gettext as _
class Company(models.Model):
name = models.CharField(
verbose_name=_("Company"), max_length=255, blank=False, null=False
)
location = models.CharField(
verbose_name=_("Location"), max_length=255, blank=False, null=False
)
color = models.CharField(
verbose_name=_("Color (hex)"),
max_length=6,
null=False,
blank=False,
default="000000",
)
logo = models.FileField(upload_to="portfolio/companies_logo/")
def __str__(self):
return self.name
class Meta:
verbose_name = _("Company")
verbose_name_plural = _("Companies")
class JobExperience(models.Model):
position = models.CharField(verbose_name=_("Position"), null=False, blank=False)
start = models.DateField(verbose_name=_("Start date"), null=False, blank=False)
end = models.DateField(verbose_name=_("End date"), null=True, blank=True)
company = models.ForeignKey(
to=Company, null=False, blank=False, on_delete=models.PROTECT
)
@property
def currently_employed(self) -> bool:
return self.end is None
def __str__(self):
end = self.end if self.end else "today"
return f"{self.company} from {self.start} to {end}"
class JobTask(models.Model):
job = models.ForeignKey(
to=JobExperience,
null=False,
blank=False,
on_delete=models.CASCADE,
related_name="job_tasks",
)
description = models.TextField(verbose_name=_("Description"), max_length=4192)
def __str__(self):
return self.job.__str__()
class ProjectTag(models.Model):
name = models.CharField(
verbose_name=_("Name"), max_length=25, blank=False, null=False
)
color = models.CharField(
verbose_name=_("Color (hex)"),
max_length=6,
null=False,
blank=False,
default="000000",
)
def __str__(self):
return self.name
class Project(models.Model):
name = models.CharField(
verbose_name=_("Name"), max_length=256, null=False, blank=False
)
short_description = models.CharField(
verbose_name=_("Short description"), max_length=512, blank=False, null=False
)
url = models.URLField(verbose_name=_("URL"), blank=True, null=False)
tags = models.ManyToManyField(
ProjectTag,
verbose_name=_("Tags"),
blank=True,
related_name="projects",
)
def __str__(self):
return self.name
+8
View File
@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% load i18n static %}
{% block content %}
<div class="container mt-4 fs-2">
<div>{% trans "Thanks for contacting me!" %}</div>
<a href="{% url 'index' %}" class="btn btn-primary mt-4">{% trans "Go back" %}</a>
</div>
{% endblock %}
+120 -13
View File
@@ -1,8 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load i18n %} {% load i18n static %}
{% block content %} {% block content %}
<div class="container"> <div class="container px-4 px-md-0">
<div class="row h-"> <div class="row mb-4">
<div class="col"> <div class="col">
<div class="text-center mt-4"> <div class="text-center mt-4">
<h1 class="display-2 fw-bold">Patrick Canal</h1> <h1 class="display-2 fw-bold">Patrick Canal</h1>
@@ -16,23 +16,130 @@
</div> </div>
<div class="mb-4 text-center"> <div class="mb-4 text-center">
<div class="mb-2"> <div class="mb-2">
<span class="badge bg-blue-lt fs-4 me-2">Python</span> <span class="badge bg-blue-lt fs-4 m-1">Python</span>
<span class="badge bg-blue-lt fs-4 me-2">Django</span> <span class="badge bg-blue-lt fs-4 m-1">Django</span>
<span class="badge bg-blue-lt fs-4 me-2">JavaScript/TypeScript</span> <span class="badge bg-blue-lt fs-4 m-1">JavaScript/TypeScript</span>
<span class="badge bg-blue-lt fs-4 me-2">HTML/CSS</span> <span class="badge bg-blue-lt fs-4 m-1">HTML/CSS</span>
</div> </div>
<div> <div>
<span class="badge bg-blue-lt fs-4 me-2">Docker</span> <span class="badge bg-blue-lt fs-4 m-1">Docker</span>
<span class="badge bg-blue-lt fs-4 me-2">Git</span> <span class="badge bg-blue-lt fs-4 m-1">Git</span>
<span class="badge bg-blue-lt fs-4 me-2">Linux</span> <span class="badge bg-blue-lt fs-4 m-1">Linux</span>
<span class="badge bg-blue-lt fs-4">NixOS</span> <span class="badge bg-blue-lt fs-4 m-1">NixOS</span>
</div> </div>
</div> </div>
<div class="text-center"> <div class="text-center">
<a href="" class="btn btn-primary btn-lg me-2">My projects</a> <a href="{% url 'projects' %}" class="btn btn-primary btn-lg me-2">My projects</a>
<a href="" class="btn btn-outline-secondary btn-lg">Contact me</a> <a href="#contact-me" class="btn btn-outline-secondary btn-lg">Contact me</a>
</div>
</div>
</div>
<div class="row row-deck row-cards">
<div class="col-md-4">
<div class="card h-full">
<div class="card-body d-flex align-items-center flex-column">
<div class="w-24 h-30">
<img src="{% static 'images/nix-logo.svg' %}" alt="NixOS logo">
</div>
<b class="fs-2">{% trans "NixOS enthusiast" %}</b>
<p class="text-secondary text-center mt-2">
{% blocktrans %}
Linux user for years my OS of choice is NixOS, embrace reproducibility!
{% endblocktrans %}
</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-full">
<div class="card-body d-flex align-items-center flex-column">
<div class="w-24 h-30">
<img src="{% static 'images/foss-logo.webp' %}" alt="FOSS logo">
</div>
<b class="fs-2">{% trans "FOSS lover" %}</b>
<p class="text-secondary text-center mt-2">
{% blocktrans %}
Every line of code that I write for myself is open source<br />
Public money should be invested in public code
{% endblocktrans %}
</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-full">
<div class="card-body d-flex align-items-center flex-column">
<div class="w-24 h-30 text-5xl pt-4">
<i class="fa-solid fa-server fa-2xl"></i>
</div>
<b class="fs-2">{% trans "Homelab maintainer" %}</b>
<p class="text-secondary text-center mt-2">
{% blocktrans %}
One of the projects that I am most proud of is my homelabbing activity,
check it out on my <a href="https://github.com/Superredstone/nixos/tree/main/machines/bomba" target="_blank">Github</a>
{% endblocktrans %}
</p>
</div>
</div>
</div>
</div>
<div class="hr"></div>
<div>
<h2 class="fs-1 pb-2">{% trans "Job experiences" %}</h2>
<ul class="timeline">
{% for je in job_experiences %}
<li class="timeline-event">
<div class="timeline-event-icon bg-primary">
<img src="{% url 'company-logo' pk=je.company.pk %}" alt="">
</div>
<div class="timeline-event-card card">
<div class="card-body">
<p class="text-primary mb-0">{{ je.start }} - {% if je.end %}{{ je.end }}{% else %}{% trans "today" %}{% endif %}</p>
<h2 class="fs-2">{{ je.position }}</h2>
<div>
<div style="color: #{{ je.company.color }};">{{ je.company.name }}</div>
<div class="text-secondary">{{ je.company.location }}</div>
<div class="mt-2 text-white">
{% for jt in je.job_tasks.all %}
<div>{{ jt.description }}</div>
{% endfor %}
</div>
</div>
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
<div class="hr"></div>
<div class="row d-flex justify-content-center mb-md-4">
<div class="card col-md-6" id="contact-me">
<div class="card-body">
<h3 class="card-title fs-2">
<b>
<i class="fa-solid fa-envelope"></i>
{% trans "Contact me" %}
</b>
</h3>
<form action="{% url 'contact' %}" method="post" id="contact-form">
{% csrf_token %}
{% for field in contact_form %}
<div class="form-floating mb-2">
{{ field }}
<label for="{{ field.id }}">{{ field.label }}</label>
</div>
{% endfor %}
<div class="d-flex justify-content-end">
<button class="btn btn-primary" type="submit">{% trans "Send" %}</button>
</div>
</form>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<style>
textarea {
height: 10rem !important;
}
</style>
{% endblock %} {% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="container mt-2 mt-md-4">
<form class="mb-2 mb-md-4">
<div class="form-floating">
<input type="text"
class="form-control"
id="floating-name"
placeholder=""
name="query"
autocomplete="off"
hx-target="#projects"
hx-get="{% url 'projects-query' %}"
hx-trigger="load, input, delay:200ms" />
<label for="floating-name">{% trans "Search" %}</label>
</div>
</form>
<div id="projects"></div>
</div>
{% endblock %}
@@ -0,0 +1,35 @@
<div class="row row-deck row-cards">
{% for project in object_list %}
<div class="col-md-4">
{% if project.url %}
<a class="card card-link card-link-pop"
href="{{ project.url }}"
target="_blank">
<div class="card-body">
<h4>{{ project.name }}</h4>
<p>{{ project.short_description }}</p>
<div>
{% for t in project.tags.all %}
<div class="badge text-white fs-5"
style="background-color: #{{ t.color }}">{{ t }}</div>
{% endfor %}
</div>
</div>
</a>
{% else %}
<div class="card">
<div class="card-body">
<h4>{{ project.name }}</h4>
<p>{{ project.short_description }}</p>
<div>
{% for t in project.tags.all %}
<div class="badge text-white fs-5"
style="background-color: #{{ t.color }}">{{ t }}</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
</div>
{% endfor %}
</div>
+11
View File
@@ -0,0 +1,11 @@
from django.urls import path
from portfolio import views
urlpatterns = [
path("", views.PortfolioView.as_view(), name="portfolio"),
path("projects/", views.ProjectList.as_view(), name="projects"),
path("projects/query/", views.ProjectListQuery.as_view(), name="projects-query"),
path("contact/", views.ContactSend.as_view(), name="contact"),
path("company/<int:pk>/logo/", views.CompanyLogo.as_view(), name="company-logo"),
]
+64 -3
View File
@@ -1,7 +1,68 @@
from django.views.generic import TemplateView from django.conf import settings
from django.db.models import Q
from django.http.response import FileResponse, HttpResponseBadRequest
from django.utils.translation import gettext as _
from django.views.generic import DetailView, ListView, TemplateView
from post_office import mail
from portfolio import forms, models
# Create your views here. class PortfolioView(TemplateView):
class IndexView(TemplateView):
template_name = "index.html" template_name = "index.html"
def get_context_data(self, **kwargs):
contact_form = forms.ContactForm()
return {
"job_experiences": models.JobExperience.objects.all().order_by("-start"),
"contact_form": contact_form,
}
class ProjectList(TemplateView):
template_name = "project_list.html"
class ContactSend(TemplateView):
template_name = "contact_thanks.html"
def post(self, request, *args, **kwargs):
form = forms.ContactForm(request.POST)
if not form.is_valid():
return HttpResponseBadRequest(_("Invalid request"))
mail.send(
subject=form.cleaned_data["subject"],
message=form.cleaned_data["message"],
sender=settings.EMAIL_HOST_USER,
cc=form.cleaned_data["email"],
recipients=[settings.EMAIL_CONTACT],
)
return super().get(request, *args, **kwargs)
class CompanyLogo(DetailView):
model = models.Company
def get(self, request, *args, **kwargs):
company = self.get_object()
return FileResponse(company.logo)
class ProjectListQuery(ListView):
template_name = "project_list_query.html"
model = models.Project
def get_queryset(self):
query = self.request.GET.get("query")
if not query:
return super().get_queryset()
return (
super()
.get_queryset()
.filter(Q(name__contains=query) | Q(tags__name__contains=query))
.distinct()
)
+4 -2
View File
@@ -7,7 +7,7 @@ max_line_length = 160
[tool.ruff] [tool.ruff]
exclude = [ exclude = [
"*migrations*", "*migrations/",
"settings.py", "settings.py",
] ]
@@ -19,4 +19,6 @@ line-ending = "auto"
[tool.ruff.lint] [tool.ruff.lint]
extend-select = ["I"] extend-select = ["I"]
ignore = [
"RUF012"
]
+4
View File
@@ -1 +1,5 @@
django django
djlint
django-post_office
gunicorn
whitenoise
+4
View File
@@ -1,3 +1,7 @@
:root { :root {
margin: 0px; margin: 0px;
} }
html {
margin: 0px!important;
}
+1
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+187
View File
@@ -0,0 +1,187 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="535"
height="535"
viewBox="0 0 501.56251 501.56249"
id="svg2"
version="1.1"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25)"
sodipodi:docname="nix-snowflake-colours.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs4">
<linearGradient
inkscape:collect="always"
id="linearGradient5562">
<stop
style="stop-color:#699ad7;stop-opacity:1"
offset="0"
id="stop5564" />
<stop
id="stop5566"
offset="0.24345198"
style="stop-color:#7eb1dd;stop-opacity:1" />
<stop
style="stop-color:#7ebae4;stop-opacity:1"
offset="1"
id="stop5568" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient5053">
<stop
style="stop-color:#415e9a;stop-opacity:1"
offset="0"
id="stop5055" />
<stop
id="stop5057"
offset="0.23168644"
style="stop-color:#4a6baf;stop-opacity:1" />
<stop
style="stop-color:#5277c3;stop-opacity:1"
offset="1"
id="stop5059" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient5562"
id="linearGradient4328"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(70.650339,-1055.1511)"
x1="200.59668"
y1="351.41116"
x2="290.08701"
y2="506.18814" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient5053"
id="linearGradient4330"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(864.69589,-1491.3405)"
x1="-584.19934"
y1="782.33563"
x2="-496.29703"
y2="937.71399" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.70904368"
inkscape:cx="99.429699"
inkscape:cy="195.33352"
inkscape:document-units="px"
inkscape:current-layer="layer3"
showgrid="false"
inkscape:window-width="1920"
inkscape:window-height="1050"
inkscape:window-x="1920"
inkscape:window-y="30"
inkscape:window-maximized="1"
inkscape:snap-global="true"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1" />
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:groupmode="layer"
id="layer3"
inkscape:label="gradient-logo"
style="display:inline;opacity:1"
transform="translate(-156.41121,933.30685)">
<g
id="g2"
transform="matrix(0.99994059,0,0,0.99994059,-0.06321798,33.188377)"
style="stroke-width:1.00006">
<path
sodipodi:nodetypes="cccccccccc"
inkscape:connector-curvature="0"
id="path3336-6"
d="m 309.54892,-710.38827 122.19683,211.67512 -56.15706,0.5268 -32.6236,-56.8692 -32.85645,56.5653 -27.90237,-0.011 -14.29086,-24.6896 46.81047,-80.4901 -33.22946,-57.8257 z"
style="opacity:1;fill:url(#linearGradient4328);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:3.00018;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<use
height="100%"
width="100%"
transform="rotate(60,407.11155,-715.78724)"
id="use3439-6"
inkscape:transform-center-y="151.59082"
inkscape:transform-center-x="124.43045"
xlink:href="#path3336-6"
y="0"
x="0"
style="stroke-width:1.00006" />
<use
height="100%"
width="100%"
transform="rotate(-60,407.31177,-715.70016)"
id="use3445-0"
inkscape:transform-center-y="75.573958"
inkscape:transform-center-x="-168.20651"
xlink:href="#path3336-6"
y="0"
x="0"
style="stroke-width:1.00006" />
<use
height="100%"
width="100%"
transform="rotate(180,407.41868,-715.7565)"
id="use3449-5"
inkscape:transform-center-y="-139.94592"
inkscape:transform-center-x="59.669705"
xlink:href="#path3336-6"
y="0"
x="0"
style="stroke-width:1.00006" />
<path
style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:url(#linearGradient4330);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:3.00018;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 309.54892,-710.38827 122.19683,211.67512 -56.15706,0.5268 -32.6236,-56.8692 -32.85645,56.5653 -27.90237,-0.011 -14.29086,-24.6896 46.81047,-80.4901 -33.22946,-57.8256 z"
id="path4260-0"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccccc" />
<use
height="100%"
width="100%"
transform="rotate(120,407.33916,-716.08356)"
id="use4354-5"
xlink:href="#path4260-0"
y="0"
x="0"
style="display:inline;stroke-width:1.00006" />
<use
height="100%"
width="100%"
transform="rotate(-120,407.28823,-715.86995)"
id="use4362-2"
xlink:href="#path4260-0"
y="0"
x="0"
style="display:inline;stroke-width:1.00006" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.4 KiB

+5 -6
View File
File diff suppressed because one or more lines are too long
+6 -11
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long