Compare commits
No commits in common. "building_backend" and "dev" have entirely different histories.
building_b
...
dev
82 changed files with 502 additions and 2581 deletions
BIN
.coverage
BIN
.coverage
Binary file not shown.
|
|
@ -1,60 +0,0 @@
|
|||
name: Build and Deploy Backend
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "backend/**"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- "deploy/deploy-backend.sh"
|
||||
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
runs-on: roadrunner
|
||||
|
||||
container:
|
||||
volumes:
|
||||
- /opt/blauwasser-api:/opt/blauwasser-api
|
||||
- /var/lib/blauwasser-api:/var/lib/blauwasser-api
|
||||
|
||||
steps:
|
||||
- name: Repository auschecken
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Python einrichten
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
# - name: Backend testen
|
||||
# working-directory: backend
|
||||
# run: |
|
||||
# python -m pip install -U pip
|
||||
# python -m pip install -e .
|
||||
# pytest
|
||||
|
||||
- name: Backend kopieren
|
||||
run: |
|
||||
rsync -a --delete \
|
||||
--exclude '.venv' \
|
||||
--exclude '__pycache__' \
|
||||
--exclude '*.db' \
|
||||
backend/ /opt/blauwasser-api/backend/
|
||||
cp pyproject.toml /opt/blauwasser-api/
|
||||
cp uv.lock /opt/blauwasser-api/
|
||||
|
||||
- name: Abhängigkeiten aktualisieren
|
||||
run: |
|
||||
cd /opt/blauwasser-api
|
||||
uv sync --frozen
|
||||
|
||||
- name: Backend neu starten
|
||||
run: |
|
||||
systemctl restart blauwasser-api
|
||||
systemctl is-active --quiet blauwasser-api
|
||||
|
||||
- name: Deployment prüfen
|
||||
run: |
|
||||
curl --fail https://api.blauwasser-mausis.de/health
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1 +1,3 @@
|
|||
node_modules
|
||||
backend/venv/lib/**
|
||||
backend/venv/lib64/*
|
||||
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
|
|
@ -2,16 +2,9 @@
|
|||
"cSpell.language": "en,de-DE",
|
||||
"cSpell.words": [
|
||||
"Chiara",
|
||||
"dotenv",
|
||||
"Hobbithöhle",
|
||||
"mailtrap",
|
||||
"Masala",
|
||||
"Pomorie",
|
||||
"Reinke"
|
||||
],
|
||||
"python.testing.pytestArgs": [
|
||||
"backend"
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true
|
||||
]
|
||||
}
|
||||
3
backend/.gitignore
vendored
3
backend/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
|||
.venv/lib/**
|
||||
.venv/lib64/*
|
||||
dev.db
|
||||
|
|
@ -1 +0,0 @@
|
|||
3.14
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Start Backend
|
||||
|
||||
```
|
||||
uv run fastapi dev
|
||||
```
|
||||
BIN
backend/__pycache__/main.cpython-312.pyc
Normal file
BIN
backend/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/dev.db
BIN
backend/dev.db
Binary file not shown.
21
backend/main.py
Normal file
21
backend/main.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from nexios import NexiosApp
|
||||
import uvicorn
|
||||
|
||||
# Create a new Nexios application
|
||||
app = NexiosApp()
|
||||
|
||||
# Define a simple route
|
||||
@app.get("/")
|
||||
async def hello_world(request, response):
|
||||
return response.json({
|
||||
"message": "Hello from Nexios!",
|
||||
"status": "success"
|
||||
})
|
||||
|
||||
# If you forget to use async def for your handler, Nexios will run the handler in a threadpool
|
||||
|
||||
# If you define two routes with the same path and method, Nexios will raise a conflict error at startup.
|
||||
|
||||
# Run the application
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app")
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
[project]
|
||||
name = "backend"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
{ name = "ebby", email = "ebbyplay@proton.me" }
|
||||
]
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"fastapi[standard]==0.136.3",
|
||||
"httpx2>=2.12.0",
|
||||
"pytest>=9.1.1",
|
||||
"sqlmodel>=0.0.42",
|
||||
"mailtrap==2.10.0",
|
||||
"fastapi-limiter==0.2.0",
|
||||
"fastapi-crons==2.5.0",
|
||||
"feedparser==6.0.14",
|
||||
"PyJWT==2.15.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
backend = "backend:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.12.9,<0.13.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[tool.fastapi]
|
||||
entrypoint = "backend.main:app"
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
[tool.pyrefly]
|
||||
|
||||
project-includes = [
|
||||
"./**/**/*.py",
|
||||
]
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
BACKEND_URL="http://localhost:8000"
|
||||
FRONTEND_URL="http://localhost:4321"
|
||||
JWT_SECRET="IamALocalSecretAndMustBeReplacedInProduction"
|
||||
ENVIRONMENT="local"
|
||||
MAILTRAP_API_TOKEN="FakedyFake"
|
||||
DATABASE_URL="sqlite:///dev.db"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,8 +0,0 @@
|
|||
import os
|
||||
|
||||
ENVIRONMENT = os.environ["ENVIRONMENT"]
|
||||
JWT_SECRET = os.environ["JWT_SECRET"]
|
||||
FRONTEND_URL = os.environ["FRONTEND_URL"]
|
||||
BACKEND_URL = os.environ["BACKEND_URL"]
|
||||
MAILTRAP_API_TOKEN = os.environ["MAILTRAP_API_TOKEN"]
|
||||
DATABASE_URL = os.environ["DATABASE_URL"]
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
from typing import Annotated
|
||||
|
||||
from fastapi import Header, HTTPException
|
||||
|
||||
|
||||
async def get_token_header(x_token: Annotated[str, Header()]):
|
||||
if x_token != "fake-super-secret-token":
|
||||
raise HTTPException(status_code=400, detail="X-Token header invalid")
|
||||
|
||||
|
||||
async def get_query_token(token: str):
|
||||
if token != "jessica":
|
||||
raise HTTPException(status_code=400, detail="No Jessica token provided")
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,51 +0,0 @@
|
|||
|
||||
from backend.config import JWT_SECRET
|
||||
from fastapi.security import HTTPBasicCredentials
|
||||
from fastapi import Security
|
||||
from fastapi.security import HTTPBearer
|
||||
from fastapi import Header
|
||||
from jwt.exceptions import PyJWTError
|
||||
from datetime import timezone
|
||||
import jwt
|
||||
from sqlmodel import select
|
||||
from backend.models.user import User
|
||||
from backend.internal.database import get_session
|
||||
from sqlmodel import Session
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Depends
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_DAYS = 180
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta | None = None):
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS))
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, JWT_SECRET, algorithm=ALGORITHM)
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
def get_token(credentials = Security(security)) -> str:
|
||||
parts = credentials.credentials.split()
|
||||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
||||
return parts[1]
|
||||
raise HTTPException(status_code=403, detail="Invalid authorization format")
|
||||
|
||||
def decode_token(token: str)-> str | None:
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM])
|
||||
return payload.get("sub")
|
||||
|
||||
async def get_current_user(token: str = Depends(get_token), session: Session = Depends(get_session)):
|
||||
try:
|
||||
user_code: str | None = decode_token(token)
|
||||
if user_code is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
except PyJWTError:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
user = session.exec(select(User).where(User.user_code == user_code)).first()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
return user
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
from backend.main import app
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
origins = [
|
||||
"http://localhost",
|
||||
]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"https://blauwasser-mausis.de",
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
from backend.config import DATABASE_URL
|
||||
from collections.abc import Generator
|
||||
from sqlmodel import Session
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel import create_engine
|
||||
|
||||
sqlite_url = DATABASE_URL
|
||||
|
||||
connect_args = {"check_same_thread": False}
|
||||
engine = create_engine(sqlite_url, connect_args=connect_args)
|
||||
|
||||
def create_db_and_tables():
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
def get_session() -> Generator[Session, None, None]:
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>Newsletter</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div>
|
||||
<h1>Neuer Artikel:</h1>
|
||||
<h3>{{data.title}}</h3>
|
||||
<h4>{{data.subtitle}}</h4>
|
||||
<p>{{data.description}}</p>
|
||||
|
||||
<a href={{data.link}}>
|
||||
<button>Zum Artikel</button>
|
||||
</a>
|
||||
|
||||
<a href="https://blauwasser-mausis.de/blog/bootsuche-england">
|
||||
<span>Abbestellen</span>
|
||||
</a>
|
||||
|
||||
<a id="admin" href="https://backend.blauwasser-mausis.de/?secret={{data.secret}}">Approve</a>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<style>
|
||||
* {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 32px;
|
||||
|
||||
}
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
background-color: lightgrey;
|
||||
padding: 32px 64px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
h3,
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 12px;
|
||||
background-color: azure;
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#admin {
|
||||
margin-top: 64px;
|
||||
color: red;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>Newsletter</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div>
|
||||
<h1>Neuer Artikel:</h1>
|
||||
<h3>{{data.title}}</h3>
|
||||
<h4>{{data.subtitle}}</h4>
|
||||
<p>{{data.description}}</p>
|
||||
|
||||
<a href={{data.link}}>
|
||||
<button>Zum Artikel</button>
|
||||
</a>
|
||||
|
||||
<a href="https://blauwasser-mausis.de/blog/bootsuche-england">
|
||||
<span>Abbestellen</span>
|
||||
</a>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<style>
|
||||
* {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 32px;
|
||||
|
||||
}
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
background-color: lightgrey;
|
||||
padding: 32px 64px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
h3,
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 12px;
|
||||
background-color: azure;
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>Newsletter</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div>
|
||||
<h1>Willkommen, {{data.name}}</h1>
|
||||
<p>Du hast dich bei Blauwasser-Mausis.de angemeldet. Solltest du das nicht gewesen sein, dann lösche die Daten einfach über den untenstehenden Link.</p>
|
||||
|
||||
<p>Dein persönlicher Code, um alle Funktionen nutzen zu können: {{data.user_code}}</p>
|
||||
|
||||
<a href="https://blauwasser-mausis.de/">
|
||||
<span>Daten löschen</span>
|
||||
</a>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<style>
|
||||
* {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 32px;
|
||||
|
||||
}
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
background-color: lightgrey;
|
||||
padding: 32px 64px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
from backend.config import MAILTRAP_API_TOKEN
|
||||
from mailtrap import BatchEmailRequest
|
||||
from mailtrap import BatchSendEmailParams
|
||||
from backend.config import ENVIRONMENT
|
||||
from mailtrap import Mail
|
||||
import mailtrap as mt
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
class ArticleData():
|
||||
def __init__(self, title, subtitle, description, link):
|
||||
self.title=title
|
||||
self.subtitle=subtitle
|
||||
self.description=description
|
||||
self.link=link
|
||||
|
||||
title: str
|
||||
subtitle: str
|
||||
description: str
|
||||
link: str
|
||||
|
||||
class AdminApprovalData(ArticleData):
|
||||
def __init__(self,title, subtitle, description, link, secret):
|
||||
ArticleData.__init__(self, title, subtitle, description, link)
|
||||
self.secret = secret
|
||||
|
||||
secret: str
|
||||
|
||||
def create_html_from_template(template_name: str, data):
|
||||
env = Environment(loader = FileSystemLoader('src/backend/internal/jinja_templates'))
|
||||
template = env.get_template(f"{template_name}.jinja")
|
||||
return template.render(data=data)
|
||||
|
||||
def send_newsletter_admin_approval(data: ArticleData ):
|
||||
html = create_html_from_template("approval", data)
|
||||
|
||||
mail = mt.Mail(
|
||||
sender=mt.Address(email="newsletter@blauwasser-mausis.de", name="Flaschenpost"),
|
||||
to=[mt.Address(email="blauwasser-mausis@proton.me")],
|
||||
subject=f"[Approval] {data.title}",
|
||||
html=html,
|
||||
)
|
||||
send_mail(mail)
|
||||
|
||||
def send_mail(mail: Mail):
|
||||
if ENVIRONMENT == "local":
|
||||
print(f"Mail sent: {mail.subject}")
|
||||
print(mail.html)
|
||||
else:
|
||||
client = mt.MailtrapClient(token=MAILTRAP_API_TOKEN)
|
||||
client.send(mail)
|
||||
|
||||
def send_batch_mail(mails: list[BatchEmailRequest]):
|
||||
if ENVIRONMENT == "local":
|
||||
for mail in mails:
|
||||
print(f"Mail sent: {mail.subject} to {mail.to}")
|
||||
else:
|
||||
client = mt.MailtrapClient(token=MAILTRAP_API_TOKEN)
|
||||
params = BatchSendEmailParams(
|
||||
base={
|
||||
"from": {
|
||||
"name": "Blauwasser Mausis",
|
||||
"email": "newsletter@blauwasser-mausis.de"
|
||||
},
|
||||
"subject": "Flaschenpost | Blauwasser Mausis",
|
||||
},
|
||||
requests=mails
|
||||
)
|
||||
client.batch_send(params)
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
|
||||
from feedparser import FeedParserDict
|
||||
from backend.config import FRONTEND_URL
|
||||
from backend.internal.authentication import create_access_token
|
||||
from backend.internal.mail_util import AdminApprovalData
|
||||
from backend.internal.mail_util import send_newsletter_admin_approval
|
||||
from datetime import timedelta
|
||||
from datetime import datetime
|
||||
import feedparser
|
||||
|
||||
def check_blog_rss_and_send_admin_mail():
|
||||
feed = feedparser.parse(f"{FRONTEND_URL}/rss.xml")
|
||||
for article in feed.entries:
|
||||
date_string = str(article["published"])
|
||||
date_object = datetime.strptime(date_string, '%a, %d %b %Y %H:%M:%S %Z')
|
||||
|
||||
if date_object > (datetime.now() - timedelta(days=16)):
|
||||
title = str(article["title"])
|
||||
description = str(article["summary"])
|
||||
subtitle = str(article["subtitle"])
|
||||
link = str(article["link"])
|
||||
secret = create_access_token({"sub": title})
|
||||
|
||||
data = AdminApprovalData(title, subtitle, description, link, secret)
|
||||
send_newsletter_admin_approval(data)
|
||||
|
||||
def find_article_by_title(title: str) -> FeedParserDict | None:
|
||||
feed = feedparser.parse(f"{FRONTEND_URL}/rss.xml")
|
||||
for article in feed.entries:
|
||||
if title == str(article["title"]):
|
||||
return article
|
||||
return None
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import string
|
||||
import random
|
||||
|
||||
def generate_user_code() -> str:
|
||||
return ''.join(random.choices(string.ascii_uppercase, k=4))
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
|
||||
from dotenv.main import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from .routers import users, comments, newsletters, health
|
||||
from backend.internal.database import create_db_and_tables
|
||||
from fastapi import FastAPI
|
||||
from fastapi_crons import Crons
|
||||
from backend.internal.rss_reader import check_blog_rss_and_send_admin_mail
|
||||
|
||||
|
||||
# TODO: TLS
|
||||
# TODO: Deploy
|
||||
|
||||
app = FastAPI()
|
||||
crons = Crons(app)
|
||||
|
||||
@crons.cron("0 14 * * *", name="RSS-Check")
|
||||
async def check_for_new_article():
|
||||
check_blog_rss_and_send_admin_mail()
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
create_db_and_tables()
|
||||
|
||||
|
||||
app.include_router(users.router)
|
||||
app.include_router(comments.router)
|
||||
app.include_router(newsletters.router)
|
||||
app.include_router(health.router)
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
from .user import User
|
||||
from .comment import Comment
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,22 +0,0 @@
|
|||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
from sqlmodel import Relationship
|
||||
from sqlmodel import Field
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.models.user import User
|
||||
|
||||
class CommentBase(SQLModel):
|
||||
pass
|
||||
|
||||
@dataclass
|
||||
class Comment(CommentBase, table=True):
|
||||
id: uuid.UUID | None = Field(default_factory=uuid.uuid7, primary_key=True)
|
||||
slug: str = Field(index=True)
|
||||
content: str = Field()
|
||||
date_time: datetime = Field(default_factory=datetime.now)
|
||||
user_id: uuid.UUID = Field(foreign_key="user.id")
|
||||
user: "User" = Relationship(back_populates="comments")
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
|
||||
# Reaction: slug and visitor_hash[(UUID or IP from Header) and salt] (foreign_key)
|
||||
|
||||
#Reference: https://isaacfei.com/posts/simple-like-system/
|
||||
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
from sqlmodel._compat import ConfigDict
|
||||
from uuid import uuid7
|
||||
from datetime import datetime
|
||||
from backend.internal.user_code_gen import generate_user_code
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
from sqlmodel import Field
|
||||
from sqlmodel import Relationship
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.models.comment import Comment
|
||||
|
||||
class UserBase(SQLModel):
|
||||
pass
|
||||
|
||||
class User(UserBase, table=True):
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid7, primary_key=True)
|
||||
email: str = Field(unique=True)
|
||||
name: str = Field(unique=True)
|
||||
user_code: str = Field(default_factory=generate_user_code,unique=True)
|
||||
subscription: bool = Field()
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
|
||||
comments: list["Comment"] = Relationship(back_populates="user")
|
||||
|
||||
@classmethod
|
||||
def factory(cls,
|
||||
id: uuid.UUID | None = None,
|
||||
email: str = "Email@Test.mail",
|
||||
name: str = "TestName",
|
||||
user_code: str = "WYZZ",
|
||||
subscription: bool = False,
|
||||
) -> "User":
|
||||
return cls(
|
||||
id=id or uuid.uuid7(),
|
||||
email=email,
|
||||
name=name,
|
||||
user_code=user_code,
|
||||
subscription=subscription,
|
||||
)
|
||||
|
||||
class NewUser(UserBase):
|
||||
email: str
|
||||
name: str
|
||||
subscription: bool
|
||||
|
||||
@classmethod
|
||||
def factory(cls,
|
||||
email: str = "Email@Test.mail",
|
||||
name: str = "TestName",
|
||||
subscription: bool = False
|
||||
) -> "NewUser":
|
||||
return cls(
|
||||
email = email,
|
||||
name = name,
|
||||
subscription = subscription
|
||||
)
|
||||
|
||||
class UserInfo(UserBase):
|
||||
name: str
|
||||
email: str
|
||||
subscription: bool
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,18 +0,0 @@
|
|||
|
||||
# add comment
|
||||
# delete comment
|
||||
# get comments
|
||||
|
||||
from backend.internal.database import get_session
|
||||
from fastapi import Depends
|
||||
from sqlmodel import Session
|
||||
from fastapi import APIRouter
|
||||
from ..models.comment import Comment
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/comments",
|
||||
tags=["comments"],
|
||||
dependencies=[],
|
||||
responses={404: {"description": "Not found"}},
|
||||
)
|
||||
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health", include_in_schema=False)
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
from mailtrap import Address
|
||||
from backend.internal.mail_util import create_html_from_template
|
||||
from mailtrap import BatchEmailRequest
|
||||
from mailtrap import BatchMail
|
||||
from backend.internal.mail_util import send_batch_mail
|
||||
from starlette.status import HTTP_400_BAD_REQUEST
|
||||
from fastapi import HTTPException
|
||||
from sqlmodel import select
|
||||
from backend.models.user import User
|
||||
from sqlmodel import Session
|
||||
from backend.internal.database import get_session
|
||||
from fastapi import Depends
|
||||
from backend.internal.rss_reader import find_article_by_title
|
||||
from backend.internal.authentication import decode_token
|
||||
from fastapi import Form
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/newsletter",
|
||||
dependencies=[],
|
||||
responses={404: {"description": "Not found"}},
|
||||
)
|
||||
|
||||
@router.post(
|
||||
path="/approve",
|
||||
status_code=201
|
||||
)
|
||||
def approve_newsletter(secret: str = Form(), session: Session = Depends(get_session)):
|
||||
title = decode_token(secret)
|
||||
if title is not None:
|
||||
article = find_article_by_title(title)
|
||||
if article is not None:
|
||||
subscribed_users = session.exec(select(User).where(User.subscription == True))
|
||||
mails: list[BatchEmailRequest] = []
|
||||
for user in subscribed_users:
|
||||
html = create_html_from_template("newsletter", user)
|
||||
mail = BatchEmailRequest(to=[Address(email=user.email,name=user.name)],html=html)
|
||||
mails.append(mail)
|
||||
send_batch_mail(mails)
|
||||
|
||||
else:
|
||||
raise HTTPException(HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
|
||||
|
||||
# addReaction
|
||||
# delete Reaction
|
||||
# get Reactions by slug
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
from mailtrap import Address
|
||||
from backend.internal.mail_util import send_mail
|
||||
from backend.internal.mail_util import create_html_from_template
|
||||
from mailtrap import Mail
|
||||
from pyrate_limiter import Duration
|
||||
from pyrate_limiter import Rate
|
||||
from pyrate_limiter import Limiter
|
||||
from backend.models.user import UserInfo
|
||||
from backend.internal.authentication import get_current_user
|
||||
from backend.internal.authentication import create_access_token
|
||||
from starlette.status import HTTP_400_BAD_REQUEST,HTTP_401_UNAUTHORIZED
|
||||
from fastapi import Form
|
||||
from sqlmodel import select
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from backend.models.user import User
|
||||
from backend.models.user import NewUser
|
||||
from backend.internal.database import get_session
|
||||
from fastapi import Depends
|
||||
from sqlmodel import Session
|
||||
from fastapi import APIRouter
|
||||
from fastapi_limiter.depends import RateLimiter
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/users",
|
||||
dependencies=[],
|
||||
responses={404: {"description": "Not found"}},
|
||||
)
|
||||
|
||||
@router.post(
|
||||
path="/",
|
||||
status_code=201,
|
||||
dependencies=[Depends(RateLimiter(limiter=Limiter(Rate(1, Duration.MINUTE))))]
|
||||
)
|
||||
def register_user(new_user: NewUser, session: Session = Depends(get_session)):
|
||||
user = User.model_validate(new_user)
|
||||
|
||||
try:
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
|
||||
mail = Mail(
|
||||
to=[Address(email=user.email,name=user.name)],
|
||||
sender=Address(email="noreply@blauwasser-mausis.de",name="Blauwasser Mausis"),
|
||||
subject=f"[Registrierung] Willkommen {user.name} | Blauwasser Mausis",
|
||||
html=create_html_from_template("welcome", user)
|
||||
)
|
||||
send_mail(mail)
|
||||
|
||||
except IntegrityError as e:
|
||||
session.rollback()
|
||||
raise HTTPException(HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@router.post(
|
||||
path="/login",
|
||||
status_code=201,
|
||||
dependencies=[Depends(RateLimiter(limiter=Limiter(Rate(3, Duration.SECOND * 30))))]
|
||||
)
|
||||
def login(user_code: str = Form(), session: Session = Depends(get_session)) -> str:
|
||||
user = session.exec(select(User).where(User.user_code == user_code)).first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED)
|
||||
return create_access_token({"sub": user.user_code})
|
||||
|
||||
|
||||
@router.post(
|
||||
path="/forgot",
|
||||
dependencies=[Depends(RateLimiter(limiter=Limiter(Rate(1, Duration.MINUTE))))]
|
||||
)
|
||||
def forgot_user_code(email: str = Form(), session: Session = Depends(get_session)):
|
||||
user = session.exec(select(User).where(User.email == email)).first()
|
||||
if user is not None:
|
||||
pass # TODO: send Mail
|
||||
|
||||
|
||||
@router.get(path="/me", status_code=201)
|
||||
def get_current_user_info(current_user: User = Depends(get_current_user)) -> UserInfo:
|
||||
return UserInfo.model_validate(current_user)
|
||||
|
||||
|
||||
@router.delete(path="/me", status_code=201)
|
||||
def delete(current_user: User = Depends(get_current_user), session: Session = Depends(get_session)) :
|
||||
session.delete(current_user)
|
||||
session.commit()
|
||||
|
||||
|
||||
@router.put(path="/me/subscribe", status_code=201)
|
||||
def change_subscription(
|
||||
subscription: bool = Form(),
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
current_user.subscription = subscription
|
||||
session.add(current_user)
|
||||
session.commit()
|
||||
|
||||
|
||||
# TODO:
|
||||
# change mail <- token, new mail
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -1,84 +0,0 @@
|
|||
from backend.models.user import NewUser
|
||||
from sqlmodel import select
|
||||
from backend.models.user import User
|
||||
from backend.internal.database import get_session
|
||||
from sqlmodel import Session
|
||||
from sqlmodel import StaticPool
|
||||
from sqlmodel import create_engine
|
||||
import pytest
|
||||
from backend.internal.database import engine
|
||||
from sqlmodel import SQLModel
|
||||
from backend.main import app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@pytest.fixture(name="session")
|
||||
def session_fixture():
|
||||
engine = create_engine(
|
||||
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
|
||||
)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture(name="client")
|
||||
def client_fixture(session: Session):
|
||||
def get_session_override():
|
||||
return session
|
||||
|
||||
app.dependency_overrides[get_session] = get_session_override
|
||||
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_register_user_saved(session:Session, client: TestClient):
|
||||
new_user = {
|
||||
"email": "Test@Test.test",
|
||||
"name": "Test1",
|
||||
"subscription": True
|
||||
}
|
||||
response = client.post(url="/users/", json=new_user)
|
||||
assert response.status_code == 201
|
||||
|
||||
db_user = session.exec(select(User).where(User.email == "Test@Test.test")).first()
|
||||
assert db_user
|
||||
assert db_user.email == "Test@Test.test"
|
||||
assert db_user.name == "Test1"
|
||||
assert db_user.subscription == True
|
||||
|
||||
def test_email_not_unique_returns_400(session: Session, client: TestClient):
|
||||
new_user = NewUser.factory(email="NotUnique")
|
||||
|
||||
user = User().factory(email="NotUnique")
|
||||
session.add(user)
|
||||
session.commit()
|
||||
response = client.post(url="/users/", json=new_user.model_dump())
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_name_not_unique_returns_400(session: Session, client: TestClient):
|
||||
new_user = NewUser.factory(email="other@other")
|
||||
|
||||
user = User().factory()
|
||||
session.add(user)
|
||||
session.commit()
|
||||
response = client.post(url="/users/", json=new_user.model_dump())
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_login_successful(session: Session, client: TestClient):
|
||||
user = User().factory()
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
|
||||
payload = {
|
||||
"user_code": user.user_code
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
url="/login",
|
||||
data=payload
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()[:2] == "ey"
|
||||
1694
backend/uv.lock
generated
1694
backend/uv.lock
generated
File diff suppressed because it is too large
Load diff
247
backend/venv/bin/Activate.ps1
Normal file
247
backend/venv/bin/Activate.ps1
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
<#
|
||||
.Synopsis
|
||||
Activate a Python virtual environment for the current PowerShell session.
|
||||
|
||||
.Description
|
||||
Pushes the python executable for a virtual environment to the front of the
|
||||
$Env:PATH environment variable and sets the prompt to signify that you are
|
||||
in a Python virtual environment. Makes use of the command line switches as
|
||||
well as the `pyvenv.cfg` file values present in the virtual environment.
|
||||
|
||||
.Parameter VenvDir
|
||||
Path to the directory that contains the virtual environment to activate. The
|
||||
default value for this is the parent of the directory that the Activate.ps1
|
||||
script is located within.
|
||||
|
||||
.Parameter Prompt
|
||||
The prompt prefix to display when this virtual environment is activated. By
|
||||
default, this prompt is the name of the virtual environment folder (VenvDir)
|
||||
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
|
||||
|
||||
.Example
|
||||
Activate.ps1
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -Verbose
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||
and shows extra information about the activation as it executes.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
|
||||
Activates the Python virtual environment located in the specified location.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -Prompt "MyPython"
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||
and prefixes the current prompt with the specified string (surrounded in
|
||||
parentheses) while the virtual environment is active.
|
||||
|
||||
.Notes
|
||||
On Windows, it may be required to enable this Activate.ps1 script by setting the
|
||||
execution policy for the user. You can do this by issuing the following PowerShell
|
||||
command:
|
||||
|
||||
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
|
||||
For more information on Execution Policies:
|
||||
https://go.microsoft.com/fwlink/?LinkID=135170
|
||||
|
||||
#>
|
||||
Param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[String]
|
||||
$VenvDir,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[String]
|
||||
$Prompt
|
||||
)
|
||||
|
||||
<# Function declarations --------------------------------------------------- #>
|
||||
|
||||
<#
|
||||
.Synopsis
|
||||
Remove all shell session elements added by the Activate script, including the
|
||||
addition of the virtual environment's Python executable from the beginning of
|
||||
the PATH variable.
|
||||
|
||||
.Parameter NonDestructive
|
||||
If present, do not remove this function from the global namespace for the
|
||||
session.
|
||||
|
||||
#>
|
||||
function global:deactivate ([switch]$NonDestructive) {
|
||||
# Revert to original values
|
||||
|
||||
# The prior prompt:
|
||||
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
|
||||
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
|
||||
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
|
||||
}
|
||||
|
||||
# The prior PYTHONHOME:
|
||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
|
||||
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
|
||||
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
}
|
||||
|
||||
# The prior PATH:
|
||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
|
||||
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
|
||||
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
|
||||
}
|
||||
|
||||
# Just remove the VIRTUAL_ENV altogether:
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV
|
||||
}
|
||||
|
||||
# Just remove VIRTUAL_ENV_PROMPT altogether.
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
|
||||
}
|
||||
|
||||
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
|
||||
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
|
||||
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
|
||||
}
|
||||
|
||||
# Leave deactivate function in the global namespace if requested:
|
||||
if (-not $NonDestructive) {
|
||||
Remove-Item -Path function:deactivate
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.Description
|
||||
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
|
||||
given folder, and returns them in a map.
|
||||
|
||||
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
|
||||
two strings separated by `=` (with any amount of whitespace surrounding the =)
|
||||
then it is considered a `key = value` line. The left hand string is the key,
|
||||
the right hand is the value.
|
||||
|
||||
If the value starts with a `'` or a `"` then the first and last character is
|
||||
stripped from the value before being captured.
|
||||
|
||||
.Parameter ConfigDir
|
||||
Path to the directory that contains the `pyvenv.cfg` file.
|
||||
#>
|
||||
function Get-PyVenvConfig(
|
||||
[String]
|
||||
$ConfigDir
|
||||
) {
|
||||
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
|
||||
|
||||
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
|
||||
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
|
||||
|
||||
# An empty map will be returned if no config file is found.
|
||||
$pyvenvConfig = @{ }
|
||||
|
||||
if ($pyvenvConfigPath) {
|
||||
|
||||
Write-Verbose "File exists, parse `key = value` lines"
|
||||
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
|
||||
|
||||
$pyvenvConfigContent | ForEach-Object {
|
||||
$keyval = $PSItem -split "\s*=\s*", 2
|
||||
if ($keyval[0] -and $keyval[1]) {
|
||||
$val = $keyval[1]
|
||||
|
||||
# Remove extraneous quotations around a string value.
|
||||
if ("'""".Contains($val.Substring(0, 1))) {
|
||||
$val = $val.Substring(1, $val.Length - 2)
|
||||
}
|
||||
|
||||
$pyvenvConfig[$keyval[0]] = $val
|
||||
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
|
||||
}
|
||||
}
|
||||
}
|
||||
return $pyvenvConfig
|
||||
}
|
||||
|
||||
|
||||
<# Begin Activate script --------------------------------------------------- #>
|
||||
|
||||
# Determine the containing directory of this script
|
||||
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$VenvExecDir = Get-Item -Path $VenvExecPath
|
||||
|
||||
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
|
||||
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
|
||||
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
|
||||
|
||||
# Set values required in priority: CmdLine, ConfigFile, Default
|
||||
# First, get the location of the virtual environment, it might not be
|
||||
# VenvExecDir if specified on the command line.
|
||||
if ($VenvDir) {
|
||||
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
|
||||
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
|
||||
Write-Verbose "VenvDir=$VenvDir"
|
||||
}
|
||||
|
||||
# Next, read the `pyvenv.cfg` file to determine any required value such
|
||||
# as `prompt`.
|
||||
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
|
||||
|
||||
# Next, set the prompt from the command line, or the config file, or
|
||||
# just use the name of the virtual environment folder.
|
||||
if ($Prompt) {
|
||||
Write-Verbose "Prompt specified as argument, using '$Prompt'"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
|
||||
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
|
||||
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
|
||||
$Prompt = $pyvenvCfg['prompt'];
|
||||
}
|
||||
else {
|
||||
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
|
||||
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
|
||||
$Prompt = Split-Path -Path $venvDir -Leaf
|
||||
}
|
||||
}
|
||||
|
||||
Write-Verbose "Prompt = '$Prompt'"
|
||||
Write-Verbose "VenvDir='$VenvDir'"
|
||||
|
||||
# Deactivate any currently active virtual environment, but leave the
|
||||
# deactivate function in place.
|
||||
deactivate -nondestructive
|
||||
|
||||
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
|
||||
# that there is an activated venv.
|
||||
$env:VIRTUAL_ENV = $VenvDir
|
||||
|
||||
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
|
||||
|
||||
Write-Verbose "Setting prompt to '$Prompt'"
|
||||
|
||||
# Set the prompt to include the env name
|
||||
# Make sure _OLD_VIRTUAL_PROMPT is global
|
||||
function global:_OLD_VIRTUAL_PROMPT { "" }
|
||||
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
|
||||
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
|
||||
|
||||
function global:prompt {
|
||||
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
|
||||
_OLD_VIRTUAL_PROMPT
|
||||
}
|
||||
$env:VIRTUAL_ENV_PROMPT = $Prompt
|
||||
}
|
||||
|
||||
# Clear PYTHONHOME
|
||||
if (Test-Path -Path Env:PYTHONHOME) {
|
||||
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
Remove-Item -Path Env:PYTHONHOME
|
||||
}
|
||||
|
||||
# Add the venv to the PATH
|
||||
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
|
||||
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
|
||||
70
backend/venv/bin/activate
Normal file
70
backend/venv/bin/activate
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# This file must be used with "source bin/activate" *from bash*
|
||||
# You cannot run it directly
|
||||
|
||||
deactivate () {
|
||||
# reset old environment variables
|
||||
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
|
||||
PATH="${_OLD_VIRTUAL_PATH:-}"
|
||||
export PATH
|
||||
unset _OLD_VIRTUAL_PATH
|
||||
fi
|
||||
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
|
||||
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
|
||||
export PYTHONHOME
|
||||
unset _OLD_VIRTUAL_PYTHONHOME
|
||||
fi
|
||||
|
||||
# Call hash to forget past commands. Without forgetting
|
||||
# past commands the $PATH changes we made may not be respected
|
||||
hash -r 2> /dev/null
|
||||
|
||||
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
|
||||
PS1="${_OLD_VIRTUAL_PS1:-}"
|
||||
export PS1
|
||||
unset _OLD_VIRTUAL_PS1
|
||||
fi
|
||||
|
||||
unset VIRTUAL_ENV
|
||||
unset VIRTUAL_ENV_PROMPT
|
||||
if [ ! "${1:-}" = "nondestructive" ] ; then
|
||||
# Self destruct!
|
||||
unset -f deactivate
|
||||
fi
|
||||
}
|
||||
|
||||
# unset irrelevant variables
|
||||
deactivate nondestructive
|
||||
|
||||
# on Windows, a path can contain colons and backslashes and has to be converted:
|
||||
if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then
|
||||
# transform D:\path\to\venv to /d/path/to/venv on MSYS
|
||||
# and to /cygdrive/d/path/to/venv on Cygwin
|
||||
export VIRTUAL_ENV=$(cygpath /home/ebby/Desktop/Bluewaterblog/backend/venv)
|
||||
else
|
||||
# use the path as-is
|
||||
export VIRTUAL_ENV=/home/ebby/Desktop/Bluewaterblog/backend/venv
|
||||
fi
|
||||
|
||||
_OLD_VIRTUAL_PATH="$PATH"
|
||||
PATH="$VIRTUAL_ENV/"bin":$PATH"
|
||||
export PATH
|
||||
|
||||
# unset PYTHONHOME if set
|
||||
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
|
||||
# could use `if (set -u; : $PYTHONHOME) ;` in bash
|
||||
if [ -n "${PYTHONHOME:-}" ] ; then
|
||||
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
|
||||
unset PYTHONHOME
|
||||
fi
|
||||
|
||||
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
|
||||
_OLD_VIRTUAL_PS1="${PS1:-}"
|
||||
PS1='(venv) '"${PS1:-}"
|
||||
export PS1
|
||||
VIRTUAL_ENV_PROMPT='(venv) '
|
||||
export VIRTUAL_ENV_PROMPT
|
||||
fi
|
||||
|
||||
# Call hash to forget past commands. Without forgetting
|
||||
# past commands the $PATH changes we made may not be respected
|
||||
hash -r 2> /dev/null
|
||||
27
backend/venv/bin/activate.csh
Normal file
27
backend/venv/bin/activate.csh
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# This file must be used with "source bin/activate.csh" *from csh*.
|
||||
# You cannot run it directly.
|
||||
|
||||
# Created by Davide Di Blasi <davidedb@gmail.com>.
|
||||
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
|
||||
|
||||
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
|
||||
|
||||
# Unset irrelevant variables.
|
||||
deactivate nondestructive
|
||||
|
||||
setenv VIRTUAL_ENV /home/ebby/Desktop/Bluewaterblog/backend/venv
|
||||
|
||||
set _OLD_VIRTUAL_PATH="$PATH"
|
||||
setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
|
||||
|
||||
|
||||
set _OLD_VIRTUAL_PROMPT="$prompt"
|
||||
|
||||
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
|
||||
set prompt = '(venv) '"$prompt"
|
||||
setenv VIRTUAL_ENV_PROMPT '(venv) '
|
||||
endif
|
||||
|
||||
alias pydoc python -m pydoc
|
||||
|
||||
rehash
|
||||
69
backend/venv/bin/activate.fish
Normal file
69
backend/venv/bin/activate.fish
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
|
||||
# (https://fishshell.com/). You cannot run it directly.
|
||||
|
||||
function deactivate -d "Exit virtual environment and return to normal shell environment"
|
||||
# reset old environment variables
|
||||
if test -n "$_OLD_VIRTUAL_PATH"
|
||||
set -gx PATH $_OLD_VIRTUAL_PATH
|
||||
set -e _OLD_VIRTUAL_PATH
|
||||
end
|
||||
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
|
||||
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
|
||||
set -e _OLD_VIRTUAL_PYTHONHOME
|
||||
end
|
||||
|
||||
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
|
||||
set -e _OLD_FISH_PROMPT_OVERRIDE
|
||||
# prevents error when using nested fish instances (Issue #93858)
|
||||
if functions -q _old_fish_prompt
|
||||
functions -e fish_prompt
|
||||
functions -c _old_fish_prompt fish_prompt
|
||||
functions -e _old_fish_prompt
|
||||
end
|
||||
end
|
||||
|
||||
set -e VIRTUAL_ENV
|
||||
set -e VIRTUAL_ENV_PROMPT
|
||||
if test "$argv[1]" != "nondestructive"
|
||||
# Self-destruct!
|
||||
functions -e deactivate
|
||||
end
|
||||
end
|
||||
|
||||
# Unset irrelevant variables.
|
||||
deactivate nondestructive
|
||||
|
||||
set -gx VIRTUAL_ENV /home/ebby/Desktop/Bluewaterblog/backend/venv
|
||||
|
||||
set -gx _OLD_VIRTUAL_PATH $PATH
|
||||
set -gx PATH "$VIRTUAL_ENV/"bin $PATH
|
||||
|
||||
# Unset PYTHONHOME if set.
|
||||
if set -q PYTHONHOME
|
||||
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
|
||||
set -e PYTHONHOME
|
||||
end
|
||||
|
||||
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
|
||||
# fish uses a function instead of an env var to generate the prompt.
|
||||
|
||||
# Save the current fish_prompt function as the function _old_fish_prompt.
|
||||
functions -c fish_prompt _old_fish_prompt
|
||||
|
||||
# With the original prompt function renamed, we can override with our own.
|
||||
function fish_prompt
|
||||
# Save the return status of the last command.
|
||||
set -l old_status $status
|
||||
|
||||
# Output the venv prompt; color taken from the blue of the Python logo.
|
||||
printf "%s%s%s" (set_color 4B8BBE) '(venv) ' (set_color normal)
|
||||
|
||||
# Restore the return status of the previous command.
|
||||
echo "exit $old_status" | .
|
||||
# Output the original/"old" prompt.
|
||||
_old_fish_prompt
|
||||
end
|
||||
|
||||
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
|
||||
set -gx VIRTUAL_ENV_PROMPT '(venv) '
|
||||
end
|
||||
8
backend/venv/bin/idna
Executable file
8
backend/venv/bin/idna
Executable file
|
|
@ -0,0 +1,8 @@
|
|||
#!/home/ebby/Desktop/Bluewaterblog/backend/venv/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from idna.cli import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
8
backend/venv/bin/nexios
Executable file
8
backend/venv/bin/nexios
Executable file
|
|
@ -0,0 +1,8 @@
|
|||
#!/home/ebby/Desktop/Bluewaterblog/backend/venv/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from nexios.cli import cli
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(cli())
|
||||
8
backend/venv/bin/pip
Executable file
8
backend/venv/bin/pip
Executable file
|
|
@ -0,0 +1,8 @@
|
|||
#!/home/ebby/Desktop/Bluewaterblog/backend/venv/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
8
backend/venv/bin/pip3
Executable file
8
backend/venv/bin/pip3
Executable file
|
|
@ -0,0 +1,8 @@
|
|||
#!/home/ebby/Desktop/Bluewaterblog/backend/venv/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
8
backend/venv/bin/pip3.12
Executable file
8
backend/venv/bin/pip3.12
Executable file
|
|
@ -0,0 +1,8 @@
|
|||
#!/home/ebby/Desktop/Bluewaterblog/backend/venv/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
1
backend/venv/bin/python
Symbolic link
1
backend/venv/bin/python
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/usr/bin/python
|
||||
1
backend/venv/bin/python3
Symbolic link
1
backend/venv/bin/python3
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
python
|
||||
1
backend/venv/bin/python3.12
Symbolic link
1
backend/venv/bin/python3.12
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
python
|
||||
8
backend/venv/bin/uvicorn
Executable file
8
backend/venv/bin/uvicorn
Executable file
|
|
@ -0,0 +1,8 @@
|
|||
#!/home/ebby/Desktop/Bluewaterblog/backend/venv/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from uvicorn.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
1
backend/venv/lib64
Symbolic link
1
backend/venv/lib64
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
lib
|
||||
5
backend/venv/pyvenv.cfg
Normal file
5
backend/venv/pyvenv.cfg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
home = /usr/bin
|
||||
include-system-site-packages = false
|
||||
version = 3.12.3
|
||||
executable = /usr/bin/python3.12
|
||||
command = /usr/bin/python -m venv /home/ebby/Desktop/Bluewaterblog/backend/venv
|
||||
|
|
@ -14,7 +14,7 @@ import BlogLink from '../../components/blog/BlogLink.astro'
|
|||
import SingleImage from '../../components/blog/SingleImage.astro'
|
||||
import DoubleImage from '../../components/blog/DoubleImage.astro'
|
||||
|
||||
Die titelgebende Frage haben wir in den letzten Tagen unglaublich oft gehört. Deshalb möchten wir euch zeigen, was für uns in nächster Zeit ansteht. Anfangen will ich in der Vergangenheit und enden in der Zukunft. In einem nächsten Artikel kommen wir sicher nochmal 'Zurück in die Zukunft' und beschreiben, was auf den zukünftigen Etappen passiert ist. <br />
|
||||
Die titelgebende Frage haben wir in den letzten Tagen unglaublich oft gehört. Deshalb möchten wir euch zeigen, was für uns in nächster Zeit ansteht. Anfangen will ich in der Vergangenheit und Enden in der Zukunft. In einem nächsten Artikel kommen wir sicher nochmal 'Zurück in die Zukunft' und beschreiben, was auf den zukünftigen Etappen passiert ist. <br />
|
||||
Jetzt muss ich aber erst mal los. Das Boarding für den Flug nach Sofia beginnt!
|
||||
|
||||
---
|
||||
|
|
@ -91,7 +91,7 @@ Da wir von Nürnberg fliegen, haben wir es uns nicht nehmen lassen das Bahn- und
|
|||
### Zukunft
|
||||
|
||||
Nach Bulgarien, wovon wir natürlich ausführlich berichten werden, geht es weiter nach England. Dort liegt an der Küste von East Essex eine unserer Favoriten. Eine Trintella IV. Um uns vom furchtbaren englischen Wetter zu erholen wird die nächste Station das erholsame Südspanien sein. Jetzt, wo die Tage in Deutschland grauer werden und die Temperaturen auch mal wieder mehr als nur ein dünnes Jäckchen verlangen, fühlt sich eine Verlängerung des Sommers außerordentlich gut an. Schon in Bulgarien blicken wir Temperaturen von durchschnittlich 28°C entgegen.
|
||||
In Almería werden wir ein ganz besonderes Boot angucken. Es hat einen Elektromotor! Wenn ich an die Vorteile, leise, sauber, wartungsarm, unabhängig, denke schlägt mein Herz \<3 höher. Die Akkukapazität fällt zwar etwas klein aus, aber nichts, was man nicht anpassen könnte. Bei den meisten anderen Booten müssen wir darauf verzichten. Die E-Mobilität ist in der Welt der Segel-Yachten noch nicht etabliert. Schon gar nicht bei denjenigen, die 40 Jahre alt sind...<br />
|
||||
In Almería werden wir ein ganz besonderes Boot angucken. Es hat einen Elektromotor! Wenn ich an die Vorteile, leise, sauber, wartungsarm, unabhängig, denke schlägt mein Herz \<3 höher. Die Akkukapazität fällt zwar etwas klein aus, aber nichts, was man nicht anpassen könnte. Bei den meisten anderen Booten müssen wir darauf verzichten. Die E-Mobilität ist in der Welt der Seegel-Yachten noch nicht etabliert. Schon gar nicht bei denjenigen, die 40 Jahre alt sind...<br />
|
||||
|
||||
Und dann? Gute Frage. Dann entscheiden wir uns für eines der Boote oder planen weiter. Je nachdem wie es läuft. <br />
|
||||
Anyway, ihr werdet es erfahren. Vielleicht dann sogar schon per Newsletter Erinnerung. Ich arbeite daran :)
|
||||
|
|
@ -102,12 +102,12 @@ Anyway, ihr werdet es erfahren. Vielleicht dann sogar schon per Newsletter Erinn
|
|||
|
||||
Immer, wenn wir mit Menschen reden, die wir länger nicht gesehen haben strömen jede Menge Fragen auf uns ein. Dabei haben wir selber so viele dazu, was bei euch los ist! <br />
|
||||
Die häufigste war "Habt ihr jetzt schon ein Boot?". <br />
|
||||
Besonders bei unserem Besuch auf der großen Betriebsversammlung unserer (noch) Firma und der Feierabendveranstaltung danach, mussten wir unsere Pläne oft erzählen. Wir haben all die Freunde wiedergetroffen, die wir seid fast zwei Monaten nicht mehr gesehen haben. Es war toll euch alle zu sehen und eure Neuigkeiten zu hören.
|
||||
Besonders bei unseren Besuch auf der großen Betriebsversammlung unserer (noch) Firma und der Feierabendveranstaltung danach, mussten wir unsere Pläne oft erzählen. Wir haben all die Freunde wiedergetroffen, die wir seid fast zwei Monaten nicht mehr gesehen haben. Es war toll euch alle zu sehen und eure Neuigkeiten zu hören.
|
||||
|
||||
Auch ein nachmittäglicher Spaziergang durch Erfurt hat uns überraschend vielen uns bekannten Menschen in die Arme getrieben. Das hat uns sehr euphorisiert. Besonders schön war es mit den Besitzern von <BlogLink text="Allride" url="https://allride.services/" /> (bester Fahrradreparaturladen in mind. Erfurt) einen Plausch zu halten. Die beiden haben ihre Passion gefunden. Das merkt man in allem, was sie tun. Lieben Dank euch, dass ihr uns immer wieder so toll mit unseren Rädern geholfen und beraten habt!
|
||||
|
||||
Insgesamt hat uns das Interesse an unseren Fortschritten Richtung Boot überwältigt. Die regen Nachfragen und vor allem, dass so viele unseren Blog lesen. Großes Danke an der Stelle. Es fühlt sich gut an, dass wir es schaffen euch ein kleines Stück mitzunehmen. Wenn irgendwann die Kommentarfunktion fertig ist geht's auch mehr in die andere Richtung. Bald, versprochen :) .
|
||||
Zu spüren wie unser Vorhaben inspiriert, zeigt uns wieder ein mal: Wir haben alles richtig gemacht. Oder sind zumindest in die richtige Richtung unterwegs. Diese Flucht aus dem Alltag, in ein neues Abenteuer in so einem unbekannten Gebiet ist magisch. Der Vorteil für euch ist, dass ihr größtenteils die romantischen Seiten mitbekommt. Nicht, dass bisher besonders viel schiefgelaufen wäre. Aber zwischen den Highlights liegt auch immer wieder viel Zeit. Wir bemühen uns auch die Anstrengungen und blöden Momente mit einzufangen. Mögen dieser wenige kommen.
|
||||
Zu spüren wie unser Vorhaben inspiriert, zeigt uns wieder ein mal: Wir haben alles richtig gemacht. Oder sind zumindest in die richtige Richtung unterwegs. Diese Flucht aus dem Alltag, in ein neues Abenteuer in so einem unbekanntem Gebiet ist magisch. Der Vorteil für euch ist, dass ihr größtenteils die romantischen Seiten mitbekommt. Nicht, dass bisher besonders viel schiefgelaufen wäre. Aber zwischen den Highlights liegt auch immer wieder viel Zeit. Wir bemühen uns auch die Anstrengungen und blöden Momente mit einzufangen. Mögen dieser wenige kommen.
|
||||
|
||||
Jetzt muss ich aber auch schon wieder den Laptop wegpacken. Wir landen gleich in Sofia.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ image:
|
|||
path: titelbild_bulgarien.webp
|
||||
alt: Chiara und Tim haben ihre Beine auf einem Steinwall liegen. Im Hintergrund sieht man das Meer.
|
||||
author: Tim
|
||||
date: 2026-09-17
|
||||
date: 2026-09-15
|
||||
slug: bootsuche-bulgarien
|
||||
time2read: 12
|
||||
---
|
||||
|
|
|
|||
|
|
@ -12,17 +12,10 @@ export async function GET(context) {
|
|||
site: context.site,
|
||||
items: posts.map(post => ({
|
||||
title: post.frontmatter.title,
|
||||
subtitle: post.frontmatter.subtitle,
|
||||
pubDate: Date.parse(post.frontmatter.date),
|
||||
description: post.frontmatter.description,
|
||||
link: `/blog/${post.frontmatter.slug}`,
|
||||
|
||||
|
||||
customData: `
|
||||
<subtitle>
|
||||
<![CDATA[${post.frontmatter.subtitle}]]>
|
||||
</subtitle>
|
||||
|
||||
`,
|
||||
link: `/blog/${post.frontmatter.slug}`
|
||||
})),
|
||||
customData: `<language>de-de</language>`,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue