mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-03-12 10:31:50 +01:00
feat: Add automatic price reduction on reposts (#691)
This commit is contained in:
@@ -5,6 +5,7 @@ import atexit, enum, json, os, re, signal, sys, textwrap # isort: skip
|
||||
import getopt # pylint: disable=deprecated-module
|
||||
import urllib.parse as urllib_parse
|
||||
from gettext import gettext as _
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
|
||||
import certifi, colorama, nodriver # isort: skip
|
||||
@@ -13,7 +14,7 @@ from wcmatch import glob
|
||||
|
||||
from . import extract, resources
|
||||
from ._version import __version__
|
||||
from .model.ad_model import MAX_DESCRIPTION_LENGTH, Ad, AdPartial
|
||||
from .model.ad_model import MAX_DESCRIPTION_LENGTH, Ad, AdPartial, calculate_auto_price
|
||||
from .model.config_model import Config
|
||||
from .update_checker import UpdateChecker
|
||||
from .utils import dicts, error_handlers, loggers, misc
|
||||
@@ -36,6 +37,145 @@ class AdUpdateStrategy(enum.Enum):
|
||||
MODIFY = enum.auto()
|
||||
|
||||
|
||||
def _repost_cycle_ready(ad_cfg:Ad, ad_file_relative:str) -> bool:
|
||||
"""
|
||||
Check if the repost cycle delay has been satisfied.
|
||||
|
||||
:param ad_cfg: The ad configuration
|
||||
:param ad_file_relative: Relative path to the ad file for logging
|
||||
:return: True if ready to apply price reduction, False otherwise
|
||||
"""
|
||||
total_reposts = ad_cfg.repost_count or 0
|
||||
delay_reposts = ad_cfg.auto_price_reduction.delay_reposts
|
||||
applied_cycles = ad_cfg.price_reduction_count or 0
|
||||
eligible_cycles = max(total_reposts - delay_reposts, 0)
|
||||
|
||||
if total_reposts <= delay_reposts:
|
||||
remaining = (delay_reposts + 1) - total_reposts
|
||||
LOG.info(
|
||||
_("Auto price reduction delayed for [%s]: waiting %s more reposts (completed %s, applied %s reductions)"),
|
||||
ad_file_relative,
|
||||
max(remaining, 1), # Clamp to 1 to avoid showing "0 more reposts" when at threshold
|
||||
total_reposts,
|
||||
applied_cycles
|
||||
)
|
||||
return False
|
||||
|
||||
if eligible_cycles <= applied_cycles:
|
||||
LOG.debug(
|
||||
_("Auto price reduction already applied for [%s]: %s reductions match %s eligible reposts"),
|
||||
ad_file_relative,
|
||||
applied_cycles,
|
||||
eligible_cycles
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _day_delay_elapsed(ad_cfg:Ad, ad_file_relative:str) -> bool:
|
||||
"""
|
||||
Check if the day delay has elapsed since the ad was last published.
|
||||
|
||||
:param ad_cfg: The ad configuration
|
||||
:param ad_file_relative: Relative path to the ad file for logging
|
||||
:return: True if the delay has elapsed, False otherwise
|
||||
"""
|
||||
delay_days = ad_cfg.auto_price_reduction.delay_days
|
||||
if delay_days == 0:
|
||||
return True
|
||||
|
||||
reference = ad_cfg.updated_on or ad_cfg.created_on
|
||||
if not reference:
|
||||
LOG.info(
|
||||
_("Auto price reduction delayed for [%s]: waiting %s days but publish timestamp missing"),
|
||||
ad_file_relative,
|
||||
delay_days
|
||||
)
|
||||
return False
|
||||
|
||||
# Note: .days truncates to whole days (e.g., 1.9 days -> 1 day)
|
||||
# This is intentional: delays count complete 24-hour periods since publish
|
||||
# Both misc.now() and stored timestamps use UTC (via misc.now()), ensuring consistent calculations
|
||||
elapsed_days = (misc.now() - reference).days
|
||||
if elapsed_days < delay_days:
|
||||
LOG.info(
|
||||
_("Auto price reduction delayed for [%s]: waiting %s days (elapsed %s)"),
|
||||
ad_file_relative,
|
||||
delay_days,
|
||||
elapsed_days
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def apply_auto_price_reduction(ad_cfg:Ad, _ad_cfg_orig:dict[str, Any], ad_file_relative:str) -> None:
|
||||
"""
|
||||
Apply automatic price reduction to an ad based on repost count and configuration.
|
||||
|
||||
This function modifies ad_cfg in-place, updating the price and price_reduction_count
|
||||
fields when a reduction is applicable.
|
||||
|
||||
:param ad_cfg: The ad configuration to potentially modify
|
||||
:param _ad_cfg_orig: The original ad configuration (unused, kept for compatibility)
|
||||
:param ad_file_relative: Relative path to the ad file for logging
|
||||
"""
|
||||
if not ad_cfg.auto_price_reduction.enabled:
|
||||
return
|
||||
|
||||
base_price = ad_cfg.price
|
||||
if base_price is None:
|
||||
LOG.warning(_("Auto price reduction is enabled for [%s] but no price is configured."), ad_file_relative)
|
||||
return
|
||||
|
||||
if ad_cfg.auto_price_reduction.min_price is not None and ad_cfg.auto_price_reduction.min_price == base_price:
|
||||
LOG.warning(
|
||||
_("Auto price reduction is enabled for [%s] but min_price equals price (%s) - no reductions will occur."),
|
||||
ad_file_relative,
|
||||
base_price
|
||||
)
|
||||
return
|
||||
|
||||
if not _repost_cycle_ready(ad_cfg, ad_file_relative):
|
||||
return
|
||||
|
||||
if not _day_delay_elapsed(ad_cfg, ad_file_relative):
|
||||
return
|
||||
|
||||
applied_cycles = ad_cfg.price_reduction_count or 0
|
||||
next_cycle = applied_cycles + 1
|
||||
|
||||
effective_price = calculate_auto_price(
|
||||
base_price = base_price,
|
||||
auto_price_reduction = ad_cfg.auto_price_reduction,
|
||||
target_reduction_cycle = next_cycle
|
||||
)
|
||||
|
||||
if effective_price is None:
|
||||
return
|
||||
|
||||
if effective_price == base_price:
|
||||
# Still increment counter so small fractional reductions can accumulate over multiple cycles
|
||||
ad_cfg.price_reduction_count = next_cycle
|
||||
LOG.info(
|
||||
_("Auto price reduction kept price %s after attempting %s reduction cycles"),
|
||||
effective_price,
|
||||
next_cycle
|
||||
)
|
||||
return
|
||||
|
||||
LOG.info(
|
||||
_("Auto price reduction applied: %s -> %s after %s reduction cycles"),
|
||||
base_price,
|
||||
effective_price,
|
||||
next_cycle
|
||||
)
|
||||
ad_cfg.price = effective_price
|
||||
ad_cfg.price_reduction_count = next_cycle
|
||||
# Note: price_reduction_count is persisted to ad_cfg_orig only after successful publish
|
||||
|
||||
|
||||
class KleinanzeigenBot(WebScrapingMixin):
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -364,7 +504,11 @@ class KleinanzeigenBot(WebScrapingMixin):
|
||||
dicts.save_dict(
|
||||
self.config_file_path,
|
||||
default_config.model_dump(exclude_none = True, exclude = {"ad_defaults": {"description"}}),
|
||||
header = "# yaml-language-server: $schema=https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/refs/heads/main/schemas/config.schema.json"
|
||||
header = (
|
||||
"# yaml-language-server: $schema="
|
||||
"https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot"
|
||||
"/refs/heads/main/schemas/config.schema.json"
|
||||
)
|
||||
)
|
||||
|
||||
def load_config(self) -> None:
|
||||
@@ -571,6 +715,18 @@ class KleinanzeigenBot(WebScrapingMixin):
|
||||
def load_ad(self, ad_cfg_orig:dict[str, Any]) -> Ad:
|
||||
return AdPartial.model_validate(ad_cfg_orig).to_ad(self.config.ad_defaults)
|
||||
|
||||
def __apply_auto_price_reduction(self, ad_cfg:Ad, _ad_cfg_orig:dict[str, Any], ad_file_relative:str) -> None:
|
||||
"""Delegate to the module-level function."""
|
||||
apply_auto_price_reduction(ad_cfg, _ad_cfg_orig, ad_file_relative)
|
||||
|
||||
def __repost_cycle_ready(self, ad_cfg:Ad, ad_file_relative:str) -> bool:
|
||||
"""Delegate to the module-level function."""
|
||||
return _repost_cycle_ready(ad_cfg, ad_file_relative)
|
||||
|
||||
def __day_delay_elapsed(self, ad_cfg:Ad, ad_file_relative:str) -> bool:
|
||||
"""Delegate to the module-level function."""
|
||||
return _day_delay_elapsed(ad_cfg, ad_file_relative)
|
||||
|
||||
async def check_and_wait_for_captcha(self, *, is_login_page:bool = True) -> None:
|
||||
try:
|
||||
captcha_timeout = self._timeout("captcha_detection")
|
||||
@@ -775,6 +931,15 @@ class KleinanzeigenBot(WebScrapingMixin):
|
||||
if self.config.publishing.delete_old_ads == "BEFORE_PUBLISH" and not self.keep_old_ads:
|
||||
await self.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title = self.config.publishing.delete_old_ads_by_title)
|
||||
|
||||
# Apply auto price reduction only for REPLACE operations (actual reposts)
|
||||
# This ensures price reductions only happen on republish, not on UPDATE
|
||||
try:
|
||||
ad_file_relative = str(Path(ad_file).relative_to(Path(self.config_file_path).parent))
|
||||
except ValueError:
|
||||
# On Windows, relative_to fails when paths are on different drives
|
||||
ad_file_relative = ad_file
|
||||
self.__apply_auto_price_reduction(ad_cfg, ad_cfg_orig, ad_file_relative)
|
||||
|
||||
LOG.info("Publishing ad '%s'...", ad_cfg.title)
|
||||
await self.web_open(f"{self.root_url}/p-anzeige-aufgeben-schritt2.html")
|
||||
else:
|
||||
@@ -979,6 +1144,22 @@ class KleinanzeigenBot(WebScrapingMixin):
|
||||
if not ad_cfg.created_on and not ad_cfg.id:
|
||||
ad_cfg_orig["created_on"] = ad_cfg_orig["updated_on"]
|
||||
|
||||
# Increment repost_count and persist price_reduction_count only for REPLACE operations (actual reposts)
|
||||
# This ensures counters only advance on republish, not on UPDATE
|
||||
if mode == AdUpdateStrategy.REPLACE:
|
||||
# Increment repost_count after successful publish
|
||||
# Note: This happens AFTER publish, so price reduction logic (which runs before publish)
|
||||
# sees the count from the PREVIOUS run. This is intentional: the first publish uses
|
||||
# repost_count=0 (no reduction), the second publish uses repost_count=1 (first reduction), etc.
|
||||
current_reposts = int(ad_cfg_orig.get("repost_count", ad_cfg.repost_count or 0))
|
||||
ad_cfg_orig["repost_count"] = current_reposts + 1
|
||||
ad_cfg.repost_count = ad_cfg_orig["repost_count"]
|
||||
|
||||
# Persist price_reduction_count after successful publish
|
||||
# This ensures failed publishes don't incorrectly increment the reduction counter
|
||||
if ad_cfg.price_reduction_count is not None and ad_cfg.price_reduction_count > 0:
|
||||
ad_cfg_orig["price_reduction_count"] = ad_cfg.price_reduction_count
|
||||
|
||||
if mode == AdUpdateStrategy.REPLACE:
|
||||
LOG.info(" -> SUCCESS: ad published with ID %s", ad_id)
|
||||
else:
|
||||
|
||||
@@ -4,18 +4,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib, json # isort: skip
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime # noqa: TC003 Move import into a type-checking block
|
||||
from typing import Annotated, Any, Dict, Final, List, Literal, Mapping, Sequence
|
||||
from decimal import ROUND_CEILING, ROUND_HALF_UP, Decimal
|
||||
from gettext import gettext as _
|
||||
from typing import Annotated, Any, Final, Literal
|
||||
|
||||
from pydantic import AfterValidator, Field, field_validator, model_validator
|
||||
from typing_extensions import Self
|
||||
|
||||
from kleinanzeigen_bot.model.config_model import AdDefaults # noqa: TC001 Move application import into a type-checking block
|
||||
from kleinanzeigen_bot.model.config_model import AdDefaults, AutoPriceReductionConfig # noqa: TC001 Move application import into a type-checking block
|
||||
from kleinanzeigen_bot.utils import dicts
|
||||
from kleinanzeigen_bot.utils.misc import parse_datetime, parse_decimal
|
||||
from kleinanzeigen_bot.utils.pydantics import ContextualModel
|
||||
|
||||
MAX_DESCRIPTION_LENGTH:Final[int] = 4000
|
||||
EURO_PRECISION:Final[Decimal] = Decimal("1")
|
||||
|
||||
|
||||
def _OPTIONAL() -> Any:
|
||||
@@ -61,6 +65,45 @@ def _validate_shipping_option_item(v:str) -> str:
|
||||
ShippingOption = Annotated[str, AfterValidator(_validate_shipping_option_item)]
|
||||
|
||||
|
||||
def _validate_auto_price_reduction_constraints(
|
||||
price:int | None,
|
||||
auto_price_reduction:AutoPriceReductionConfig | dict[str, Any] | None
|
||||
) -> None:
|
||||
"""
|
||||
Validate auto_price_reduction configuration constraints.
|
||||
|
||||
Raises ValueError if:
|
||||
- auto_price_reduction is enabled but price is None
|
||||
- min_price exceeds price
|
||||
"""
|
||||
if not auto_price_reduction:
|
||||
return
|
||||
|
||||
# Handle both dict (from before validation) and AutoPriceReductionConfig (after validation)
|
||||
if isinstance(auto_price_reduction, dict):
|
||||
enabled = auto_price_reduction.get("enabled", False)
|
||||
min_price = auto_price_reduction.get("min_price")
|
||||
else:
|
||||
enabled = auto_price_reduction.enabled
|
||||
min_price = auto_price_reduction.min_price
|
||||
|
||||
if not enabled:
|
||||
return
|
||||
|
||||
if price is None:
|
||||
raise ValueError(_("price must be specified when auto_price_reduction is enabled"))
|
||||
|
||||
if min_price is not None:
|
||||
try:
|
||||
min_price_dec = Decimal(str(min_price))
|
||||
price_dec = Decimal(str(price))
|
||||
except Exception:
|
||||
# Let Pydantic's type validation surface the underlying issue
|
||||
return
|
||||
if min_price_dec > price_dec:
|
||||
raise ValueError(_("min_price must not exceed price"))
|
||||
|
||||
|
||||
class AdPartial(ContextualModel):
|
||||
active:bool | None = _OPTIONAL()
|
||||
type:Literal["OFFER", "WANTED"] | None = _OPTIONAL()
|
||||
@@ -69,14 +112,28 @@ class AdPartial(ContextualModel):
|
||||
description_prefix:str | None = _OPTIONAL()
|
||||
description_suffix:str | None = _OPTIONAL()
|
||||
category:str
|
||||
special_attributes:Dict[str, str] | None = _OPTIONAL()
|
||||
special_attributes:dict[str, str] | None = _OPTIONAL()
|
||||
price:int | None = _OPTIONAL()
|
||||
price_type:Literal["FIXED", "NEGOTIABLE", "GIVE_AWAY", "NOT_APPLICABLE"] | None = _OPTIONAL()
|
||||
auto_price_reduction:AutoPriceReductionConfig | None = Field(
|
||||
default = None,
|
||||
description = "automatic price reduction configuration"
|
||||
)
|
||||
repost_count:int = Field(
|
||||
default = 0,
|
||||
ge = 0,
|
||||
description = "number of successful publications for this ad (persisted between runs)"
|
||||
)
|
||||
price_reduction_count:int = Field(
|
||||
default = 0,
|
||||
ge = 0,
|
||||
description = "internal counter: number of automatic price reductions already applied"
|
||||
)
|
||||
shipping_type:Literal["PICKUP", "SHIPPING", "NOT_APPLICABLE"] | None = _OPTIONAL()
|
||||
shipping_costs:float | None = _OPTIONAL()
|
||||
shipping_options:List[ShippingOption] | None = _OPTIONAL()
|
||||
shipping_options:list[ShippingOption] | None = _OPTIONAL()
|
||||
sell_directly:bool | None = _OPTIONAL()
|
||||
images:List[str] | None = _OPTIONAL()
|
||||
images:list[str] | None = _OPTIONAL()
|
||||
contact:ContactPartial | None = _OPTIONAL()
|
||||
republication_interval:int | None = _OPTIONAL()
|
||||
|
||||
@@ -106,13 +163,19 @@ class AdPartial(ContextualModel):
|
||||
|
||||
@model_validator(mode = "before")
|
||||
@classmethod
|
||||
def _validate_price_and_price_type(cls, values:Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _validate_price_and_price_type(cls, values:dict[str, Any]) -> dict[str, Any]:
|
||||
price_type = values.get("price_type")
|
||||
price = values.get("price")
|
||||
auto_price_reduction = values.get("auto_price_reduction")
|
||||
|
||||
if price_type == "GIVE_AWAY" and price is not None:
|
||||
raise ValueError("price must not be specified when price_type is GIVE_AWAY")
|
||||
if price_type == "FIXED" and price is None:
|
||||
raise ValueError("price is required when price_type is FIXED")
|
||||
|
||||
# Validate auto_price_reduction configuration
|
||||
_validate_auto_price_reduction_constraints(price, auto_price_reduction)
|
||||
|
||||
return values
|
||||
|
||||
def update_content_hash(self) -> Self:
|
||||
@@ -120,7 +183,14 @@ class AdPartial(ContextualModel):
|
||||
|
||||
# 1) Dump to a plain dict, excluding the metadata fields:
|
||||
raw = self.model_dump(
|
||||
exclude = {"id", "created_on", "updated_on", "content_hash"},
|
||||
exclude = {
|
||||
"id",
|
||||
"created_on",
|
||||
"updated_on",
|
||||
"content_hash",
|
||||
"repost_count",
|
||||
"price_reduction_count",
|
||||
},
|
||||
exclude_none = True,
|
||||
exclude_unset = True,
|
||||
)
|
||||
@@ -162,11 +232,70 @@ class AdPartial(ContextualModel):
|
||||
target = ad_cfg,
|
||||
defaults = ad_defaults.model_dump(),
|
||||
ignore = lambda k, _: k == "description", # ignore legacy global description config
|
||||
override = lambda _, v: not isinstance(v, list) and v in {None, ""} # noqa: PLC1901 can be simplified
|
||||
override = lambda _, v: (
|
||||
not isinstance(v, list) and (v is None or (isinstance(v, str) and v == "")) # noqa: PLC1901
|
||||
)
|
||||
)
|
||||
# Ensure internal counters are integers (not user-configurable)
|
||||
if not isinstance(ad_cfg.get("price_reduction_count"), int):
|
||||
ad_cfg["price_reduction_count"] = 0
|
||||
if not isinstance(ad_cfg.get("repost_count"), int):
|
||||
ad_cfg["repost_count"] = 0
|
||||
return Ad.model_validate(ad_cfg)
|
||||
|
||||
|
||||
def calculate_auto_price(
|
||||
*,
|
||||
base_price:int | float | None,
|
||||
auto_price_reduction:AutoPriceReductionConfig | None,
|
||||
target_reduction_cycle:int
|
||||
) -> int | None:
|
||||
"""
|
||||
Calculate the effective price for the current run using commercial rounding.
|
||||
|
||||
Args:
|
||||
base_price: original configured price used as the starting point.
|
||||
auto_price_reduction: reduction configuration (enabled, strategy, amount, min_price, delays).
|
||||
target_reduction_cycle: which reduction cycle to calculate the price for (0 = no reduction, 1 = first reduction, etc.).
|
||||
|
||||
Percentage reductions apply to the current price each cycle (compounded). Each reduction step is rounded
|
||||
to full euros (commercial rounding with ROUND_HALF_UP) before the next reduction is applied.
|
||||
Returns an int representing whole euros, or None when base_price is None.
|
||||
"""
|
||||
if base_price is None:
|
||||
return None
|
||||
|
||||
price = Decimal(str(base_price))
|
||||
|
||||
if not auto_price_reduction or not auto_price_reduction.enabled or target_reduction_cycle <= 0:
|
||||
return int(price.quantize(EURO_PRECISION, rounding = ROUND_HALF_UP))
|
||||
|
||||
if auto_price_reduction.strategy is None or auto_price_reduction.amount is None:
|
||||
return int(price.quantize(EURO_PRECISION, rounding = ROUND_HALF_UP))
|
||||
|
||||
if auto_price_reduction.min_price is None:
|
||||
raise ValueError(_("min_price must be specified when auto_price_reduction is enabled"))
|
||||
|
||||
# Prices are published as whole euros; ensure the configured floor cannot be undercut by int() conversion.
|
||||
price_floor = Decimal(str(auto_price_reduction.min_price)).quantize(EURO_PRECISION, rounding = ROUND_CEILING)
|
||||
repost_cycles = target_reduction_cycle
|
||||
|
||||
for _cycle in range(repost_cycles):
|
||||
reduction_value = (
|
||||
price * Decimal(str(auto_price_reduction.amount)) / Decimal("100")
|
||||
if auto_price_reduction.strategy == "PERCENTAGE"
|
||||
else Decimal(str(auto_price_reduction.amount))
|
||||
)
|
||||
price -= reduction_value
|
||||
# Commercial rounding: round to full euros after each reduction step
|
||||
price = price.quantize(EURO_PRECISION, rounding = ROUND_HALF_UP)
|
||||
if price <= price_floor:
|
||||
price = price_floor
|
||||
break
|
||||
|
||||
return int(price)
|
||||
|
||||
|
||||
# pyright: reportGeneralTypeIssues=false, reportIncompatibleVariableOverride=false
|
||||
class Contact(ContactPartial):
|
||||
name:str
|
||||
@@ -183,3 +312,12 @@ class Ad(AdPartial):
|
||||
sell_directly:bool
|
||||
contact:Contact
|
||||
republication_interval:int
|
||||
auto_price_reduction:AutoPriceReductionConfig = Field(default_factory = AutoPriceReductionConfig)
|
||||
price_reduction_count:int = 0
|
||||
|
||||
@model_validator(mode = "after")
|
||||
def _validate_auto_price_config(self) -> "Ad":
|
||||
# Validate the final Ad object after merging with defaults
|
||||
# This ensures the merged configuration is valid even if raw YAML had None values
|
||||
_validate_auto_price_reduction_constraints(self.price, self.auto_price_reduction)
|
||||
return self
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import Annotated, Any, List, Literal
|
||||
from gettext import gettext as _
|
||||
from typing import Annotated, Any, Final, Literal
|
||||
|
||||
from pydantic import AfterValidator, Field, model_validator
|
||||
from typing_extensions import deprecated
|
||||
@@ -14,6 +15,52 @@ from kleinanzeigen_bot.utils import dicts
|
||||
from kleinanzeigen_bot.utils.misc import get_attr
|
||||
from kleinanzeigen_bot.utils.pydantics import ContextualModel
|
||||
|
||||
_MAX_PERCENTAGE:Final[int] = 100
|
||||
|
||||
|
||||
class AutoPriceReductionConfig(ContextualModel):
|
||||
enabled:bool = Field(
|
||||
default = False,
|
||||
description = "automatically lower the price of reposted ads"
|
||||
)
|
||||
strategy:Literal["FIXED", "PERCENTAGE"] | None = Field(
|
||||
default = None,
|
||||
description = "PERCENTAGE reduces by a percentage of the previous price, FIXED reduces by a fixed amount"
|
||||
)
|
||||
amount:float | None = Field(
|
||||
default = None,
|
||||
gt = 0,
|
||||
description = "magnitude of the reduction; interpreted as percent for PERCENTAGE or currency units for FIXED"
|
||||
)
|
||||
min_price:float | None = Field(
|
||||
default = None,
|
||||
ge = 0,
|
||||
description = "required when enabled is true; minimum price floor (use 0 for no lower bound)"
|
||||
)
|
||||
delay_reposts:int = Field(
|
||||
default = 0,
|
||||
ge = 0,
|
||||
description = "number of reposts to wait before applying the first automatic price reduction"
|
||||
)
|
||||
delay_days:int = Field(
|
||||
default = 0,
|
||||
ge = 0,
|
||||
description = "number of days to wait after publication before applying automatic price reductions"
|
||||
)
|
||||
|
||||
@model_validator(mode = "after")
|
||||
def _validate_config(self) -> "AutoPriceReductionConfig":
|
||||
if self.enabled:
|
||||
if self.strategy is None:
|
||||
raise ValueError(_("strategy must be specified when auto_price_reduction is enabled"))
|
||||
if self.amount is None:
|
||||
raise ValueError(_("amount must be specified when auto_price_reduction is enabled"))
|
||||
if self.min_price is None:
|
||||
raise ValueError(_("min_price must be specified when auto_price_reduction is enabled"))
|
||||
if self.strategy == "PERCENTAGE" and self.amount > _MAX_PERCENTAGE:
|
||||
raise ValueError(_("Percentage reduction amount must not exceed %s") % _MAX_PERCENTAGE)
|
||||
return self
|
||||
|
||||
|
||||
class ContactDefaults(ContextualModel):
|
||||
name:str | None = None
|
||||
@@ -35,9 +82,13 @@ class AdDefaults(ContextualModel):
|
||||
description_prefix:str | None = Field(default = None, description = "prefix for the ad description")
|
||||
description_suffix:str | None = Field(default = None, description = " suffix for the ad description")
|
||||
price_type:Literal["FIXED", "NEGOTIABLE", "GIVE_AWAY", "NOT_APPLICABLE"] = "NEGOTIABLE"
|
||||
auto_price_reduction:AutoPriceReductionConfig = Field(
|
||||
default_factory = AutoPriceReductionConfig,
|
||||
description = "automatic price reduction configuration"
|
||||
)
|
||||
shipping_type:Literal["PICKUP", "SHIPPING", "NOT_APPLICABLE"] = "SHIPPING"
|
||||
sell_directly:bool = Field(default = False, description = "requires shipping_type SHIPPING to take effect")
|
||||
images:List[str] | None = Field(default = None)
|
||||
images:list[str] | None = Field(default = None)
|
||||
contact:ContactDefaults = Field(default_factory = ContactDefaults)
|
||||
republication_interval:int = 7
|
||||
|
||||
@@ -62,7 +113,7 @@ class DownloadConfig(ContextualModel):
|
||||
default = False,
|
||||
description = "if true, all shipping options matching the package size will be included"
|
||||
)
|
||||
excluded_shipping_options:List[str] = Field(
|
||||
excluded_shipping_options:list[str] = Field(
|
||||
default_factory = list,
|
||||
description = "list of shipping options to exclude, e.g. ['DHL_2', 'DHL_5']"
|
||||
)
|
||||
@@ -79,7 +130,7 @@ class DownloadConfig(ContextualModel):
|
||||
|
||||
|
||||
class BrowserConfig(ContextualModel):
|
||||
arguments:List[str] = Field(
|
||||
arguments:list[str] = Field(
|
||||
default_factory = lambda: ["--user-data-dir=.temp/browser-profile"],
|
||||
description = "See https://peter.sh/experiments/chromium-command-line-switches/"
|
||||
)
|
||||
@@ -87,7 +138,7 @@ class BrowserConfig(ContextualModel):
|
||||
default = None,
|
||||
description = "path to custom browser executable, if not specified will be looked up on PATH"
|
||||
)
|
||||
extensions:List[str] = Field(
|
||||
extensions:list[str] = Field(
|
||||
default_factory = list,
|
||||
description = "a list of .crx extension files to be loaded"
|
||||
)
|
||||
@@ -175,7 +226,7 @@ GlobPattern = Annotated[str, AfterValidator(_validate_glob_pattern)]
|
||||
|
||||
|
||||
class Config(ContextualModel):
|
||||
ad_files:List[GlobPattern] = Field(
|
||||
ad_files:list[GlobPattern] = Field(
|
||||
default_factory = lambda: ["./**/ad_*.{json,yml,yaml}"],
|
||||
min_items = 1,
|
||||
description = """
|
||||
|
||||
@@ -96,6 +96,17 @@ kleinanzeigen_bot/__init__.py:
|
||||
"Skipping because ad is reserved": "Überspringen, da Anzeige reserviert ist"
|
||||
"DONE: (Re-)published %s": "FERTIG: %s (erneut) veröffentlicht"
|
||||
"ad": "Anzeige"
|
||||
apply_auto_price_reduction:
|
||||
"Auto price reduction is enabled for [%s] but no price is configured.": "Automatische Preisreduzierung ist für [%s] aktiviert, aber es wurde kein Preis konfiguriert."
|
||||
"Auto price reduction is enabled for [%s] but min_price equals price (%s) - no reductions will occur.": "Automatische Preisreduzierung ist für [%s] aktiviert, aber min_price entspricht dem Preis (%s) - es werden keine Reduktionen auftreten."
|
||||
"Auto price reduction applied: %s -> %s after %s reduction cycles": "Automatische Preisreduzierung angewendet: %s -> %s nach %s Reduktionszyklen"
|
||||
"Auto price reduction kept price %s after attempting %s reduction cycles": "Automatische Preisreduzierung hat Preis %s beibehalten nach dem Versuch von %s Reduktionszyklen"
|
||||
_repost_cycle_ready:
|
||||
"Auto price reduction delayed for [%s]: waiting %s more reposts (completed %s, applied %s reductions)": "Automatische Preisreduzierung für [%s] verzögert: Warte %s weitere erneute Veröffentlichungen (abgeschlossen %s, angewendet %s Reduktionen)"
|
||||
"Auto price reduction already applied for [%s]: %s reductions match %s eligible reposts": "Automatische Preisreduzierung für [%s] bereits angewendet: %s Reduktionen entsprechen %s berechtigten erneuten Veröffentlichungen"
|
||||
_day_delay_elapsed:
|
||||
"Auto price reduction delayed for [%s]: waiting %s days (elapsed %s)": "Automatische Preisreduzierung für [%s] verzögert: Warte %s Tage (vergangen %s)"
|
||||
"Auto price reduction delayed for [%s]: waiting %s days but publish timestamp missing": "Automatische Preisreduzierung für [%s] verzögert: Warte %s Tage, aber Zeitstempel der Veröffentlichung fehlt"
|
||||
|
||||
publish_ad:
|
||||
"Publishing ad '%s'...": "Veröffentliche Anzeige '%s'..."
|
||||
@@ -531,6 +542,24 @@ kleinanzeigen_bot/update_checker.py:
|
||||
? "You are on a different commit than the release for channel '%s' (tag: %s). This may mean you are ahead, behind, or on a different branch. Local commit: %s (%s UTC), Release commit: %s (%s UTC)"
|
||||
: "Sie befinden sich auf einem anderen Commit als das Release für Kanal '%s' (Tag: %s). Dies kann bedeuten, dass Sie voraus, hinterher oder auf einem anderen Branch sind. Lokaler Commit: %s (%s UTC), Release-Commit: %s (%s UTC)"
|
||||
|
||||
#################################################
|
||||
kleinanzeigen_bot/model/config_model.py:
|
||||
#################################################
|
||||
_validate_config:
|
||||
"strategy must be specified when auto_price_reduction is enabled": "strategy muss angegeben werden, wenn auto_price_reduction aktiviert ist"
|
||||
"amount must be specified when auto_price_reduction is enabled": "amount muss angegeben werden, wenn auto_price_reduction aktiviert ist"
|
||||
"min_price must be specified when auto_price_reduction is enabled": "min_price muss angegeben werden, wenn auto_price_reduction aktiviert ist"
|
||||
"Percentage reduction amount must not exceed %s": "Prozentuale Reduktionsmenge darf %s nicht überschreiten"
|
||||
|
||||
#################################################
|
||||
kleinanzeigen_bot/model/ad_model.py:
|
||||
#################################################
|
||||
_validate_auto_price_reduction_constraints:
|
||||
"price must be specified when auto_price_reduction is enabled": "price muss angegeben werden, wenn auto_price_reduction aktiviert ist"
|
||||
"min_price must not exceed price": "min_price darf price nicht überschreiten"
|
||||
calculate_auto_price:
|
||||
"min_price must be specified when auto_price_reduction is enabled": "min_price muss angegeben werden, wenn auto_price_reduction aktiviert ist"
|
||||
|
||||
#################################################
|
||||
kleinanzeigen_bot/model/update_check_state.py:
|
||||
#################################################
|
||||
|
||||
@@ -192,6 +192,14 @@ def __get_message_template(error_code:str) -> str | None:
|
||||
case "decimal_max_digits": return _("Decimal input should have no more than {max_digits} digit{expected_plural} in total")
|
||||
case "decimal_max_places": return _("Decimal input should have no more than {decimal_places} decimal place{expected_plural}")
|
||||
case "decimal_whole_digits": return _("Decimal input should have no more than {whole_digits} digit{expected_plural} before the decimal point")
|
||||
case "complex_type": return _("Input should be a valid python complex object, a number, or a valid complex string following the rules at https://docs.python.org/3/library/functions.html#complex")
|
||||
case "complex_str_parsing": return _("Input should be a valid complex string following the rules at https://docs.python.org/3/library/functions.html#complex")
|
||||
case "complex_type":
|
||||
return _(
|
||||
"Input should be a valid python complex object, a number, or a valid complex string "
|
||||
"following the rules at https://docs.python.org/3/library/functions.html#complex"
|
||||
)
|
||||
case "complex_str_parsing":
|
||||
return _(
|
||||
"Input should be a valid complex string following the rules at "
|
||||
"https://docs.python.org/3/library/functions.html#complex"
|
||||
)
|
||||
case _: return None
|
||||
|
||||
Reference in New Issue
Block a user