Etter mange bugfix etter første dag i drift

This commit is contained in:
Erol Haagenrud 2026-04-17 22:46:57 +02:00
parent 700f5aa08d
commit d9c747e83d
361 changed files with 2729 additions and 805 deletions

View file

@ -68,6 +68,7 @@ SMTP_PORT = os.getenv("SMTP_PORT", "").strip()
SMTP_USER = os.getenv("SMTP_USER", "").strip()
SMTP_PASS = os.getenv("SMTP_PASS", "").strip()
PUBLIC_FROM_EMAIL = os.getenv("PUBLIC_FROM_EMAIL", SMTP_USER).strip()
CONTACT_FORM_TO_EMAIL = os.getenv("CONTACT_FORM_TO_EMAIL", "teeoff@teeoff.no").strip()
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
@ -84,6 +85,9 @@ ADMIN_SESSION_MAX_AGE_SECONDS = get_int_env("ADMIN_SESSION_MAX_AGE_SECONDS", 60
ADMIN_REMEMBER_ME_MAX_AGE_SECONDS = get_int_env("ADMIN_REMEMBER_ME_MAX_AGE_SECONDS", 60 * 60 * 24 * 30)
PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES = get_int_env("PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES", 20)
PUBLIC_MAGIC_LINK_REQUEST_COOLDOWN_SECONDS = get_int_env("PUBLIC_MAGIC_LINK_REQUEST_COOLDOWN_SECONDS", 60)
CONTACT_FORM_RATE_LIMIT_WINDOW_SECONDS = get_int_env("CONTACT_FORM_RATE_LIMIT_WINDOW_SECONDS", 60 * 60)
CONTACT_FORM_RATE_LIMIT_MAX_SUBMISSIONS = get_int_env("CONTACT_FORM_RATE_LIMIT_MAX_SUBMISSIONS", 3)
CONTACT_FORM_MIN_FILL_SECONDS = get_int_env("CONTACT_FORM_MIN_FILL_SECONDS", 5)
def resolve_imported_meninger_path() -> Path:
@ -122,6 +126,10 @@ def is_magic_link_configured() -> bool:
return bool(SMTP_SERVER and SMTP_PORT and SMTP_USER and SMTP_PASS and PUBLIC_FROM_EMAIL)
def is_contact_form_configured() -> bool:
return is_magic_link_configured() and bool(CONTACT_FORM_TO_EMAIL)
def get_public_auth_config() -> dict[str, Any]:
google_enabled = is_google_login_configured()
magic_link_enabled = is_magic_link_configured()
@ -256,6 +264,40 @@ async def send_magic_link_email(email_address: str, login_url: str) -> None:
await asyncio.to_thread(_send)
async def send_contact_form_email(
*,
sender_name: str,
sender_email: str,
topic: str,
message: str,
ip_hash: str | None,
) -> None:
subject = f"[TeeOff Kontakt] {topic}"
body = (
"Ny melding fra kontaktskjemaet på TeeOff.no\n\n"
f"Navn: {sender_name}\n"
f"E-post: {sender_email}\n"
f"Emne: {topic}\n"
f"IP-hash: {ip_hash or 'ukjent'}\n\n"
"Melding:\n"
f"{message.strip()}\n"
)
def _send() -> None:
mail = EmailMessage()
mail["From"] = PUBLIC_FROM_EMAIL
mail["To"] = CONTACT_FORM_TO_EMAIL
mail["Reply-To"] = sender_email
mail["Subject"] = subject
mail.set_content(body)
with smtplib.SMTP_SSL(SMTP_SERVER, int(SMTP_PORT)) as server:
server.login(SMTP_USER, SMTP_PASS)
server.send_message(mail)
await asyncio.to_thread(_send)
async def validate_admin_session_token(token: str) -> str:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
@ -338,6 +380,8 @@ class ArticleUpsertRequest(BaseModel):
author_name: Optional[str] = None
status: Optional[str] = "draft"
hero_images: Optional[List[dict[str, Any]]] = []
media_gallery: Optional[List[dict[str, Any]]] = []
featured_media_id: Optional[str] = None
content_html: Optional[str] = None
source_url: Optional[str] = None
source_label: Optional[str] = None
@ -352,6 +396,15 @@ class PublicCommentCreateRequest(BaseModel):
class PublicMagicLinkRequest(BaseModel):
email: str
return_to: Optional[str] = "/"
class PublicContactFormRequest(BaseModel):
name: str
email: str
topic: str
message: str
website: Optional[str] = ""
started_at: Optional[int] = None
# --- FUNKSJONER ---
def format_row(row):
"""
@ -527,6 +580,43 @@ def format_article_row(row):
elif not isinstance(hero_images, list):
data["hero_images"] = []
media_gallery = data.get("media_gallery")
if media_gallery is None:
data["media_gallery"] = []
elif isinstance(media_gallery, str):
try:
data["media_gallery"] = json.loads(media_gallery)
except Exception:
data["media_gallery"] = []
elif not isinstance(media_gallery, list):
data["media_gallery"] = []
if not data["media_gallery"] and data["hero_images"]:
data["media_gallery"] = [
{
"id": build_article_media_id("image", image.get("src") or ""),
"type": "image",
"src": image.get("src") or "",
"alt": image.get("alt") or "",
"caption": image.get("caption") or "",
"poster": "",
}
for image in data["hero_images"]
if isinstance(image, dict) and str(image.get("src") or "").strip()
]
if not data.get("featured_media_id") and data["media_gallery"]:
first_image = next(
(
item
for item in data["media_gallery"]
if isinstance(item, dict) and str(item.get("type") or "image").strip().lower() == "image"
),
None,
)
if first_image:
data["featured_media_id"] = str(first_image.get("id") or "").strip() or None
return data
@ -575,7 +665,7 @@ def sanitize_hero_images(value: Any) -> list[dict[str, str]]:
continue
sanitized.append(
{
"src": src,
"src": normalize_article_media_url(src),
"alt": str(item.get("alt") or "").strip(),
"caption": str(item.get("caption") or "").strip(),
}
@ -583,6 +673,145 @@ def sanitize_hero_images(value: Any) -> list[dict[str, str]]:
return sanitized
LEGACY_ARTICLE_MEDIA_PATTERN = re.compile(
r"^https?://(?:www\.)?(?:teeoff\.no|nye\.teeoff\.no|wp\.teeoff\.no)(?P<path>/(?:wp-content/uploads|uploads/articles)/.+)$",
re.IGNORECASE,
)
YOUTUBE_THUMBNAIL_PATTERN = re.compile(
r"^https?://i\.ytimg\.com/vi(?:_webp)?/(?P<video_id>[^/]+)/",
re.IGNORECASE,
)
def normalize_article_media_url(value: str | None) -> str:
trimmed = str(value or "").strip()
if not trimmed:
return ""
legacy_match = LEGACY_ARTICLE_MEDIA_PATTERN.match(trimmed)
if legacy_match:
path = legacy_match.group("path")
if path.startswith("/wp-content/uploads/"):
return f"https://wp.teeoff.no{path}"
return path
return trimmed
def build_article_media_id(media_type: str, src: str) -> str:
digest = hashlib.sha1(f"{media_type}:{src}".encode("utf-8")).hexdigest()[:12]
return f"{media_type}-{digest}"
def sanitize_article_media(value: Any, title: str | None = None) -> list[dict[str, str]]:
if not isinstance(value, list):
return []
sanitized: list[dict[str, str]] = []
seen: set[tuple[str, str]] = set()
fallback_text = str(title or "").strip()
for item in value:
if not isinstance(item, dict):
continue
media_type = str(item.get("type") or "image").strip().lower()
if media_type not in {"image", "video"}:
continue
src = normalize_article_media_url(item.get("src"))
if not src:
continue
dedupe_key = (media_type, src)
if dedupe_key in seen:
continue
seen.add(dedupe_key)
poster = normalize_article_media_url(item.get("poster"))
alt = str(item.get("alt") or "").strip()
caption = str(item.get("caption") or "").strip()
media_id = str(item.get("id") or "").strip() or build_article_media_id(media_type, src)
sanitized.append(
{
"id": media_id,
"type": media_type,
"src": src,
"alt": alt or fallback_text,
"caption": caption or alt or fallback_text,
"poster": poster,
}
)
return sanitized
def build_media_gallery_from_hero_images(hero_images: list[dict[str, str]]) -> list[dict[str, str]]:
return [
{
"id": build_article_media_id("image", image["src"]),
"type": "image",
"src": image["src"],
"alt": image.get("alt") or "",
"caption": image.get("caption") or "",
"poster": "",
}
for image in hero_images
if image.get("src")
]
def sanitize_featured_media_id(featured_media_id: str | None, media_gallery: list[dict[str, str]]) -> str | None:
candidate = str(featured_media_id or "").strip()
if candidate and any(item.get("id") == candidate and item.get("type") == "image" for item in media_gallery):
return candidate
for item in media_gallery:
if item.get("type") == "image":
return item.get("id")
return None
def build_hero_images_from_media_gallery(
media_gallery: list[dict[str, str]],
fallback_hero_images: list[dict[str, str]],
featured_media_id: str | None,
) -> list[dict[str, str]]:
image_media = [
{
"src": item["src"],
"alt": item.get("alt") or "",
"caption": item.get("caption") or item.get("alt") or "",
"id": item.get("id") or "",
}
for item in media_gallery
if item.get("type") == "image" and item.get("src")
]
if not image_media:
return fallback_hero_images
if featured_media_id:
featured_index = next(
(index for index, item in enumerate(image_media) if item.get("id") == featured_media_id),
None,
)
if featured_index is not None and featured_index > 0:
featured_item = image_media.pop(featured_index)
image_media.insert(0, featured_item)
return [
{
"src": item["src"],
"alt": item.get("alt") or "",
"caption": item.get("caption") or item.get("alt") or "",
}
for item in image_media
]
def humanize_slug(slug: str | None) -> str:
if not slug:
return "Ukjent bane"
@ -898,6 +1127,8 @@ async def ensure_articles_table(conn):
author_name VARCHAR(255),
status VARCHAR(32) NOT NULL DEFAULT 'draft',
hero_images JSONB NOT NULL DEFAULT '[]'::jsonb,
media_gallery JSONB NOT NULL DEFAULT '[]'::jsonb,
featured_media_id VARCHAR(255),
content_html TEXT,
source_url TEXT,
source_label VARCHAR(255),
@ -910,6 +1141,14 @@ async def ensure_articles_table(conn):
ALTER TABLE articles
ADD COLUMN IF NOT EXISTS section VARCHAR(32) NOT NULL DEFAULT 'banebesok'
""")
await conn.execute("""
ALTER TABLE articles
ADD COLUMN IF NOT EXISTS media_gallery JSONB NOT NULL DEFAULT '[]'::jsonb
""")
await conn.execute("""
ALTER TABLE articles
ADD COLUMN IF NOT EXISTS featured_media_id VARCHAR(255)
""")
await conn.execute("""
UPDATE articles
SET section = 'banebesok'
@ -1017,6 +1256,7 @@ async def lifespan(app: FastAPI):
await ensure_articles_table(conn)
await ensure_public_user_tables(conn)
await ensure_scrape_jobs_table(conn)
app.state.contact_submission_tracker = {}
print("✅ Database tilkoblet og pool opprettet")
except Exception as e:
print(f"❌ Databasefeil under oppstart: {e}")
@ -1362,6 +1602,79 @@ async def request_magic_link(request: Request, payload: PublicMagicLinkRequest):
}
@app.post("/api/public/contact")
async def submit_public_contact_form(request: Request, payload: PublicContactFormRequest):
if not is_contact_form_configured():
raise HTTPException(status_code=503, detail="Kontaktskjema er ikke konfigurert ennå.")
if str(payload.website or "").strip():
return {
"status": "success",
"detail": "Takk for meldingen. Vi svarer så snart vi kan.",
}
name = str(payload.name or "").strip()
email = normalize_public_email(payload.email)
topic = str(payload.topic or "").strip()
message = str(payload.message or "").strip()
if len(name) < 2 or len(name) > 120:
raise HTTPException(status_code=400, detail="Oppgi et gyldig navn.")
if not email or "@" not in email or len(email) > 255:
raise HTTPException(status_code=400, detail="Oppgi en gyldig e-postadresse.")
if len(topic) < 2 or len(topic) > 140:
raise HTTPException(status_code=400, detail="Oppgi et gyldig emne.")
if len(message) < 20 or len(message) > 5000:
raise HTTPException(status_code=400, detail="Meldingen må være mellom 20 og 5000 tegn.")
now_ts = int(datetime.utcnow().timestamp())
if payload.started_at and now_ts - int(payload.started_at) < CONTACT_FORM_MIN_FILL_SECONDS:
return {
"status": "success",
"detail": "Takk for meldingen. Vi svarer så snart vi kan.",
}
ip_hash = hash_request_ip(request)
tracker: dict[str, list[int]] = getattr(app.state, "contact_submission_tracker", {})
cutoff = now_ts - CONTACT_FORM_RATE_LIMIT_WINDOW_SECONDS
for key in list(tracker.keys()):
recent = [ts for ts in tracker.get(key, []) if ts >= cutoff]
if recent:
tracker[key] = recent
else:
tracker.pop(key, None)
rate_keys = [f"email:{email}"]
if ip_hash:
rate_keys.append(f"ip:{ip_hash}")
for key in rate_keys:
attempts = tracker.get(key, [])
if len(attempts) >= CONTACT_FORM_RATE_LIMIT_MAX_SUBMISSIONS:
raise HTTPException(
status_code=429,
detail="For mange meldinger på kort tid. Prøv igjen senere.",
)
await send_contact_form_email(
sender_name=name,
sender_email=email,
topic=topic,
message=message,
ip_hash=ip_hash,
)
for key in rate_keys:
tracker.setdefault(key, []).append(now_ts)
app.state.contact_submission_tracker = tracker
return {
"status": "success",
"detail": "Takk for meldingen. Vi svarer så snart vi kan.",
}
@app.get("/api/public/auth/magic-link/verify")
async def verify_magic_link(
request: Request,
@ -1720,18 +2033,23 @@ async def upsert_admin_article(request: ArticleUpsertRequest):
if status == "published" and not published_at:
published_at = datetime.utcnow()
hero_images = sanitize_hero_images(request.hero_images)
fallback_hero_images = sanitize_hero_images(request.hero_images)
media_gallery = sanitize_article_media(request.media_gallery, request.title.strip())
if not media_gallery and fallback_hero_images:
media_gallery = build_media_gallery_from_hero_images(fallback_hero_images)
featured_media_id = sanitize_featured_media_id(request.featured_media_id, media_gallery)
hero_images = build_hero_images_from_media_gallery(media_gallery, fallback_hero_images, featured_media_id)
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow("""
INSERT INTO articles (
section, slug, title, description, excerpt, eyebrow, location_label,
facility_name, facility_slug, author_name, status, hero_images,
content_html, source_url, source_label, published_at, updated_at
media_gallery, featured_media_id, content_html, source_url, source_label, published_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7,
$8, $9, $10, $11, $12::jsonb,
$13, $14, $15, $16, $17
$13::jsonb, $14, $15, $16, $17, $18, $19
)
ON CONFLICT (slug) DO UPDATE SET
section = EXCLUDED.section,
@ -1745,6 +2063,8 @@ async def upsert_admin_article(request: ArticleUpsertRequest):
author_name = EXCLUDED.author_name,
status = EXCLUDED.status,
hero_images = EXCLUDED.hero_images,
media_gallery = EXCLUDED.media_gallery,
featured_media_id = EXCLUDED.featured_media_id,
content_html = EXCLUDED.content_html,
source_url = EXCLUDED.source_url,
source_label = EXCLUDED.source_label,
@ -1764,6 +2084,8 @@ async def upsert_admin_article(request: ArticleUpsertRequest):
(request.author_name or "TeeOff").strip(),
status,
json.dumps(hero_images),
json.dumps(media_gallery),
featured_media_id,
request.content_html or "",
(request.source_url or "").strip() or None,
(request.source_label or "").strip() or None,
@ -1810,28 +2132,45 @@ async def seed_admin_articles_from_imported_json():
featured_image = item.get("featuredImage") or {}
section, eyebrow = resolve_imported_article_section(item)
hero_images: list[dict[str, str]] = []
media_gallery: list[dict[str, str]] = []
featured_url = str(featured_image.get("url") or "").strip()
if featured_url:
hero_images.append(
media_gallery.append(
{
"id": build_article_media_id("image", featured_url),
"type": "image",
"src": featured_url,
"alt": str(featured_image.get("alt") or item.get("title") or "").strip(),
"caption": str(featured_image.get("caption") or item.get("title") or "").strip(),
"poster": "",
}
)
for url in extract_html_image_urls(content_html)[:5]:
if any(existing["src"] == url for existing in hero_images):
if any(existing["src"] == url for existing in media_gallery):
continue
hero_images.append(
media_gallery.append(
{
"id": build_article_media_id("image", url),
"type": "image",
"src": url,
"alt": str(item.get("title") or "").strip(),
"caption": str(item.get("title") or "").strip(),
"poster": "",
}
)
sanitized_media_gallery = sanitize_article_media(media_gallery, str(item.get("title") or "").strip())
featured_media_id = sanitize_featured_media_id(
sanitized_media_gallery[0]["id"] if sanitized_media_gallery else None,
sanitized_media_gallery,
)
hero_images = build_hero_images_from_media_gallery(
sanitized_media_gallery,
[],
featured_media_id,
)
published_at = parse_optional_datetime(item.get("publishedAt"))
updated_at = parse_optional_datetime(item.get("updatedAt")) or published_at or datetime.utcnow()
@ -1839,11 +2178,11 @@ async def seed_admin_articles_from_imported_json():
INSERT INTO articles (
section, slug, title, description, excerpt, eyebrow, location_label,
facility_name, facility_slug, author_name, status, hero_images,
content_html, source_url, source_label, published_at, updated_at
media_gallery, featured_media_id, content_html, source_url, source_label, published_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7,
$8, $9, $10, 'published', $11::jsonb,
$12, $13, $14, $15, $16
$12::jsonb, $13, $14, $15, $16, $17, $18
)
ON CONFLICT (slug) DO UPDATE SET
section = EXCLUDED.section,
@ -1857,6 +2196,8 @@ async def seed_admin_articles_from_imported_json():
author_name = EXCLUDED.author_name,
status = EXCLUDED.status,
hero_images = EXCLUDED.hero_images,
media_gallery = EXCLUDED.media_gallery,
featured_media_id = EXCLUDED.featured_media_id,
content_html = EXCLUDED.content_html,
source_url = EXCLUDED.source_url,
source_label = EXCLUDED.source_label,
@ -1874,6 +2215,8 @@ async def seed_admin_articles_from_imported_json():
str(facility_slug) if facility_slug else None,
str(((item.get("author") or {}).get("name")) or "TeeOff"),
json.dumps(hero_images),
json.dumps(sanitized_media_gallery),
featured_media_id,
content_html,
str(item.get("link") or "").strip() or None,
"Importert fra gamle TeeOff",

View file

@ -1,7 +1,7 @@
"""
TEE OFF - VEIEN TIL GOLF (VTG) SKRAPER MED GEMINI AI
---------------------------------------------------------------------------
Henter pris, beskrivelse (inkl. lånekøller/medlemskap) og kursdatoer fra VTG-sider.
Henter pris, beskrivelse (inkl. lånekøller/medlemskap/pakker) og kursdatoer fra VTG-sider.
Støtter kommaseparerte URL-er.
---------------------------------------------------------------------------
"""
@ -58,9 +58,15 @@ Du er en ekspert på norske golfklubber. Din oppgave er å lese en lang tekst fr
OPPGAVER:
1. Finn standardprisen for VTG-kurset for en vanlig voksen person. (Returner KUN tallet).
VIKTIG PRISLOGIKK:
- Hvis klubben tilbyr både "kun VTG-kurs" og en pakke som inkluderer medlemskap/spillerett, skal du velge prisen KUN SELVE KURSET som foreslatt_vtg_pris.
- Hvis klubben tilbyr et rimeligere kurs uten medlemskap, men også en dyrere pakke med medlemskap, er det alltid kursprisen uten medlemskap som skal returneres.
- Hvis klubben BARE tilbyr en samlet pakke med VTG + medlemskap/spillerett, returnerer du pakkeprisen.
- Ignorer medlemskapstilbud som ikke faktisk er knyttet til VTG-kurset.
- Ignorer priser for barn, junior, student, familie eller andre spesialgrupper hvis det finnes en vanlig voksenpris.
2. Skriv en KOMPRIMERT, selgende beskrivelse (maks 3-4 setninger). Du inkludere informasjon om:
- Er lån av køller/utstyr inkludert i kurset?
- Inkluderer prisen et medlemskap/spillerett i klubben (og ev. for hvor lenge)?
- Hvis klubben tilbyr både kurs uten medlemskap og en egen pakke med medlemskap, du nevne dette eksplisitt og oppgi pakkeprisen hvis den finnes i teksten.
- Hva er omfanget? (F.eks. "12 timer praksis pluss e-læring").
Ignorer uvesentlig støy og lange historiske utgreiinger.
3. Finn alle kommende kursdatoer. Finn startdato/sluttdato for hvert kurs, og noter status ("Ledig", "Fulltegnet", "Venteliste").
@ -72,14 +78,17 @@ OPPGAVE:
Returner KUN et gyldig JSON-objekt med nøyaktig følgende struktur:
{{
"foreslatt_vtg_pris": 1990,
"foreslatt_vtg_beskrivelse": "Kurset går over 12 timer inkludert obligatorisk e-læring. Lån av golfkøller er inkludert under hele kurset, og prisen gir deg også fritt spill og medlemskap ut året.",
"foreslatt_vtg_beskrivelse": "Kurset går over 12 timer inkludert obligatorisk e-læring, og lån av golfkøller er inkludert. Selve VTG-kurset koster 1990 kroner. Klubben tilbyr også en pakke med kurs og medlemskap ut året til 3490 kroner.",
"foreslatt_vtg_datoer": [
{{"dato": "12.-14. mai", "status": "Fulltegnet"}},
{{"dato": "5.-7. juni", "status": "Ledig"}}
],
"ai_begrunnelse": "Fant voksenpris på 1990,-. Teksten nevnte eksplisitt at medlemskap ut året er med i prisen, og at man får låne utstyr."
"ai_begrunnelse": "Fant voksenpris på 1990 kroner for selve VTG-kurset. Det stod også at klubben har en egen pakke med medlemskap til 3490 kroner, samt at lån av utstyr er inkludert."
}}
Merk: Sett foreslatt_vtg_pris til null (null) hvis du ikke finner den. Hvis du ikke finner datoer, la listen være tom [].
Merk:
- Sett foreslatt_vtg_pris til null (null) hvis du ikke finner en tydelig voksenpris.
- Hvis du ikke finner datoer, la listen være tom [].
- Hvis prisen du returnerer faktisk er en pakkepris med medlemskap, det sies tydelig i foreslatt_vtg_beskrivelse og ai_begrunnelse.
"""
try:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Some files were not shown because too many files have changed in this diff Show more