Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Interval field #573

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
2 changes: 1 addition & 1 deletion docs/advanced/custom-field/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ class CustomField(BaseField):

For data processing you will need to override two functions:

* `process_form_data`: Will be call when converting field value into python dict object
* `parse_form_data`: Will be call when converting field value into python dict object
* `serialize_field_value`: Will be call when serializing value to send through the API. This is the same data
you will get in your *render* function

Expand Down
5 changes: 5 additions & 0 deletions starlette_admin/contrib/odmantic/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
EmailField,
HasOne,
IntegerField,
IntervalField,
ListField,
StringField,
URLField,
Expand Down Expand Up @@ -109,6 +110,10 @@ def conv_bson_int64(self, *args: Any, **kwargs: Any) -> BaseField:
def conv_bson_decimal(self, *args: Any, **kwargs: Any) -> BaseField:
return DecimalField(**self._standard_type_common(**kwargs))

@converts("timedelta")
def conv_bson_timedelta(self, *args: Any, **kwargs: Any) -> BaseField:
return IntervalField(**self._standard_type_common(**kwargs))

Comment on lines +113 to +116
Copy link
Owner

@jowilf jowilf Sep 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems odmantic does not support timedelta art049/odmantic#120, we can remove this and just keep for sqlalchemy

@converts(odmantic.bson._datetime)
def conv_bson_datetime(self, *args: Any, **kwargs: Any) -> BaseField:
return DateTimeField(**self._standard_type_common(**kwargs))
Expand Down
7 changes: 7 additions & 0 deletions starlette_admin/contrib/sqla/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
HasMany,
HasOne,
IntegerField,
IntervalField,
JSONField,
ListField,
PasswordField,
Expand Down Expand Up @@ -204,6 +205,12 @@ def conv_time(self, *args: Any, **kwargs: Any) -> BaseField:
**self._field_common(*args, **kwargs),
)

@converts("Interval")
def conv_interval(self, *args: Any, **kwargs: Any) -> BaseField:
return IntervalField(
**self._field_common(*args, **kwargs),
)

@converts("Enum")
def conv_enum(self, *args: Any, **kwargs: Any) -> BaseField:
_type = kwargs["type"]
Expand Down
116 changes: 114 additions & 2 deletions starlette_admin/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import warnings
from dataclasses import asdict, dataclass
from dataclasses import field as dc_field
from datetime import date, datetime, time
from datetime import date, datetime, time, timedelta
from enum import Enum, IntEnum
from json import JSONDecodeError
from typing import (
Expand All @@ -18,10 +18,16 @@
Union,
)

from babel.dates import format_timedelta
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to make this optional and require it only when using the Interval field. here is an example:

if not arrow: # pragma: no cover

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example is not clear to me. Is the arrow package only imported when using the ArrowField? Looks more like the user cannot use the arrow field if the import fails.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the user is not able to use it if the import fails, but they are required to install it before using the field. The idea is to make the arrow package optional, so users who don't want to use ArrowField are not forced to install it.

from starlette.datastructures import FormData, UploadFile
from starlette.requests import Request
from starlette_admin._types import RequestAction
from starlette_admin.helpers import extract_fields, html_params, is_empty_file
from starlette_admin.helpers import (
extract_fields,
html_params,
is_empty_file,
timedelta_to_components,
)
from starlette_admin.i18n import (
format_date,
format_datetime,
Expand Down Expand Up @@ -1267,3 +1273,109 @@ def additional_css_links(

def additional_js_links(self, request: Request, action: RequestAction) -> List[str]:
return self.field.additional_js_links(request, action)


@dataclass
class IntervalField(StringField):
form_template: str = "forms/interval.html"

async def parse_form_data(
self, request: Request, form_data: FormData, action: RequestAction
) -> Any:
try:
timedelta_params = {
"weeks": (
0
if form_data.get(f"{self.id}_weeks") == ""
else int(form_data.get(f"{self.id}_weeks")) # type: ignore
),
"days": (
0
if form_data.get(f"{self.id}_days") == ""
else int(form_data.get(f"{self.id}_days")) # type: ignore
),
"hours": (
0
if form_data.get(f"{self.id}_hours") == ""
else int(form_data.get(f"{self.id}_hours")) # type: ignore
),
"minutes": (
0
if form_data.get(f"{self.id}_minutes") == ""
else int(form_data.get(f"{self.id}_minutes")) # type: ignore
),
"seconds": (
0
if form_data.get(f"{self.id}_seconds") == ""
else int(form_data.get(f"{self.id}_seconds")) # type: ignore
),
"microseconds": (
0
if form_data.get(f"{self.id}_microseconds") == ""
else int(form_data.get(f"{self.id}_microseconds")) # type: ignore
),
"milliseconds": (
0
if form_data.get(f"{self.id}_milliseconds") == ""
else int(form_data.get(f"{self.id}_milliseconds")) # type: ignore
),
}
return timedelta(**timedelta_params)
except ValueError:
return timedelta()

async def serialize_value(
self, request: Request, value: Any, action: RequestAction
) -> Any:
params = timedelta_to_components(value)
if action != RequestAction.EDIT:
string = (
format_timedelta(
timedelta(weeks=params["weeks"]),
granularity="week",
threshold=params["weeks"],
locale="en",
)
+ " "
if params["weeks"] > 0
else ""
)
string += (
format_timedelta(
timedelta(days=params["days"]),
granularity="day",
threshold=1,
locale="en",
)
+ " "
)
string += (
format_timedelta(
timedelta(hours=params["hours"]),
granularity="hour",
threshold=1,
locale="en",
)
+ " "
)
string += (
format_timedelta(
timedelta(minutes=params["minutes"]),
granularity="minute",
threshold=1,
locale="en",
)
+ " "
)
string += format_timedelta(
timedelta(
seconds=params["seconds"],
milliseconds=params["milliseconds"],
microseconds=params["microseconds"],
),
granularity="second",
threshold=1,
locale="en",
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this function returns the current user locale

Suggested change
locale="en",
locale=get_locale(),

)
return string
return params
32 changes: 32 additions & 0 deletions starlette_admin/helpers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
import re
from datetime import timedelta
from typing import (
TYPE_CHECKING,
Any,
Expand Down Expand Up @@ -151,3 +152,34 @@ def not_none(value: Optional[T]) -> T:
if value is not None:
return value
raise ValueError("Value can not be None") # pragma: no cover


def timedelta_to_components(td: timedelta) -> dict:
# Constants
seconds_in_minute = 60
seconds_in_hour = 3600
seconds_in_week = 604800

# Total seconds in the timedelta
total_seconds = td.total_seconds()

# Calculate each component
weeks = int(total_seconds // seconds_in_week)
total_seconds -= weeks * seconds_in_week

days = td.days % 7
hours = td.seconds // seconds_in_hour
minutes = (td.seconds % seconds_in_hour) // seconds_in_minute
seconds = td.seconds % seconds_in_minute
milliseconds = td.microseconds // 1000
microseconds = td.microseconds % 1000

return {
regisin marked this conversation as resolved.
Show resolved Hide resolved
"weeks": weeks,
"days": days,
"hours": hours,
"minutes": minutes,
"seconds": seconds,
"milliseconds": milliseconds,
"microseconds": microseconds,
}
57 changes: 57 additions & 0 deletions starlette_admin/templates/forms/interval.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<fieldset class="form-fieldset">
<div class="{% if error %}{{ field.error_class }}{% endif %} form-group row mb-3">
<label class="col-2 col-form-label"
for="{{ field.id }}_weeks">Weeks:</label>
<div class="col-2">
<input type="number" class="form-control {% if error %}is-invalid{% endif %}" id="{{ field.id }}_weeks"
name="{{ field.id }}_weeks" value="{{ '' if data is none else data.weeks }}" {{ field.input_params() | safe }}/>
</div>

<label class="col-2 col-form-label"
for="{{ field.id }}_days">Days:</label>
<div class="col-2">
<input type="number" class="form-control {% if error %}is-invalid{% endif %}" id="{{ field.id }}_days"
name="{{ field.id }}_days" value="{{ '' if data is none else data.days }}" {{ field.input_params() | safe }}/>
</div>

<label class="col-2 col-form-label"
for="{{ field.id }}_hours">Hours:</label>
<div class="col-2">
<input type="number" class="form-control {% if error %}is-invalid{% endif %}" id="{{ field.id }}_hours"
name="{{ field.id }}_hours" value="{{ '' if data is none else data.hours }}" {{ field.input_params() | safe }}/>
</div>

<label class="col-2 col-form-label"
for="{{ field.id }}_minutes">Minutes:</label>
<div class="col-2">
<input type="number" class="form-control {% if error %}is-invalid{% endif %}" id="{{ field.id }}_minutes"
name="{{ field.id }}_minutes" value="{{ '' if data is none else data.minutes }}" {{ field.input_params() | safe }}/>
</div>

<label class="col-2 col-form-label"
for="{{ field.id }}_seconds">Seconds:</label>
<div class="col-2">
<input type="number" class="form-control {% if error %}is-invalid{% endif %}" id="{{ field.id }}_seconds"
name="{{ field.id }}_seconds" value="{{ '' if data is none else data.seconds }}" {{ field.input_params() | safe }}/>
</div>

<label class="col-2 col-form-label"
for="{{ field.id }}_milliseconds">Milliseconds:</label>
<div class="col-2">
<input type="number" class="form-control {% if error %}is-invalid{% endif %}" id="{{ field.id }}_milliseconds"
name="{{ field.id }}_milliseconds" value="{{ '' if data is none else data.milliseconds }}" {{ field.input_params() | safe }}/>
</div>

<label class="col-2 col-form-label"
for="{{ field.id }}_microseconds">Microseconds:</label>
<div class="col-2">
<input type="number" class="form-control {% if error %}is-invalid{% endif %}" id="{{ field.id }}_microseconds"
name="{{ field.id }}_microseconds" value="{{ '' if data is none else data.microseconds }}" {{ field.input_params() | safe }}/>
</div>
{% if field.help_text %}
<small class="form-hint">{{ field.help_text }}</small>
{% endif %}
</div>

</fieldset>
{% include "forms/_error.html" %}
78 changes: 78 additions & 0 deletions tests/test_timedelta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from datetime import timedelta

from starlette_admin.helpers import timedelta_to_components


def test_zero_timedelta():
td = timedelta()
expected = {
"weeks": 0,
"days": 0,
"hours": 0,
"minutes": 0,
"seconds": 0,
"milliseconds": 0,
"microseconds": 0,
}
result = timedelta_to_components(td)
assert result == expected


def test_only_days():
td = timedelta(days=10)
expected = {
"weeks": 1,
"days": 3,
"hours": 0,
"minutes": 0,
"seconds": 0,
"milliseconds": 0,
"microseconds": 0,
}
result = timedelta_to_components(td)
assert result == expected


def test_complex_timedelta():
td = timedelta(days=15, seconds=3661, microseconds=123456)
expected = {
"weeks": 2,
"days": 1,
"hours": 1,
"minutes": 1,
"seconds": 1,
"milliseconds": 123,
"microseconds": 456,
}
result = timedelta_to_components(td)
assert result == expected


def test_no_microseconds():
td = timedelta(days=7, seconds=86399)
expected = {
"weeks": 1,
"days": 0,
"hours": 23,
"minutes": 59,
"seconds": 59,
"milliseconds": 0,
"microseconds": 0,
}
result = timedelta_to_components(td)
assert result == expected


def test_negative_timedelta():
td = timedelta(days=-7, seconds=-86399)
expected = {
"weeks": -2,
"days": 6,
"hours": 0,
"minutes": 0,
"seconds": 1,
"milliseconds": 0,
"microseconds": 0,
}
result = timedelta_to_components(td)
assert result == expected
Loading