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

Datetime display options #885

Merged
merged 12 commits into from
Jan 13, 2022
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions mathesar/api/display_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,9 @@
{
"options": [{"name": "show_as_percentage", "type": "boolean"},
{"name": "locale", "type": "string"}]
},
MathesarTypeIdentifier.DATETIME.value:
{
"options": [{"name": "format", "type": "string"}]
}
}
4 changes: 2 additions & 2 deletions mathesar/api/serializers/columns.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,13 @@ def to_representation(self, instance):
if isinstance(instance, dict):
instance_type = instance.get('type')
else:
instance_type = instance.type
instance_type = instance.plain_type
self.context[DISPLAY_OPTIONS_SERIALIZER_MAPPING_KEY] = str(instance_type)
return super().to_representation(instance)

def to_internal_value(self, data):
if self.partial and 'type' not in data:
instance_type = getattr(self.instance, 'type', None)
instance_type = getattr(self.instance, 'plain_type', None)
if instance_type is not None:
self.context[DISPLAY_OPTIONS_SERIALIZER_MAPPING_KEY] = str(instance_type)
else:
Expand Down
115 changes: 107 additions & 8 deletions mathesar/api/serializers/shared_serializers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from abc import ABC, abstractmethod

import arrow
from django.core.exceptions import ImproperlyConfigured
from rest_framework import serializers

Expand All @@ -9,13 +12,12 @@ class ReadOnlyPolymorphicSerializerMappingMixin:
This serializer mixin is helpful in serializing polymorphic models,
by switching to correct serializer based on the mapping field value.
"""

def __new__(cls, *args, **kwargs):
if cls.serializers_mapping is None:
raise ImproperlyConfigured(
'`{cls}` is missing a '
'`{cls}.model_serializer_mapping` attribute'.format(
cls=cls.__name__
)
'`{cls}.model_serializer_mapping` attribute'.format(cls=cls.__name__)
)
return super().__new__(cls, *args, **kwargs)

Expand Down Expand Up @@ -50,8 +52,7 @@ class ReadWritePolymorphicSerializerMappingMixin(ReadOnlyPolymorphicSerializerMa
def to_internal_value(self, data):
serializer = self.serializers_mapping.get(self.get_mapping_field())
if serializer is not None:
return serializer.to_internal_value(
data=data)
return serializer.to_internal_value(data=data)
else:
raise Exception(f"Cannot find a matching serializer for the specified type {self.get_mapping_field()}")

Expand Down Expand Up @@ -81,6 +82,7 @@ class OverrideRootPartialMixin:
Refer to the issue
https://github.com/encode/django-rest-framework/issues/3847
"""

def run_validation(self, *args, **kwargs):
if not self.partial:
with MonkeyPatchPartial(self.root):
Expand All @@ -106,9 +108,106 @@ class NumberDisplayOptionSerializer(OverrideRootPartialMixin, serializers.Serial
locale = serializers.CharField(required=False)


class AbstractDateTimeFormatValidator(ABC):
requires_context = True

def __init__(self):
pass

def __call__(self, value, serializer_field):
self.date_format_validator(value)

def date_format_validator(self, value):
try:
timestamp_with_tz_obj = arrow.get('2013-09-30T15:34:00.000-07:00')
parsed_datetime_str = timestamp_with_tz_obj.format(value)
datetime_object = arrow.get(parsed_datetime_str, value)
except ValueError:
raise serializers.ValidationError(f"{value} is not a valid format used for parsing a datetime.")
else:
self.validate(datetime_object, value)

@abstractmethod
def validate(self, datetime_obj, display_format):
mathemancer marked this conversation as resolved.
Show resolved Hide resolved
pass


class TimestampWithTimeZoneFormatValidator(AbstractDateTimeFormatValidator):

def validate(self, datetime_obj, display_format):
pass


class TimestampWithoutTimeZoneFormatValidator(AbstractDateTimeFormatValidator):

def validate(self, datetime_obj, display_format):
if 'z' in display_format.lower():
raise serializers.ValidationError(
"Timestamp without timezone column cannot contain timezone display format")


class DateFormatValidator(AbstractDateTimeFormatValidator):

def validate(self, datetime_obj, display_format):
date_obj = arrow.get('2013-09-30')
if datetime_obj.time() != date_obj:
mathemancer marked this conversation as resolved.
Show resolved Hide resolved
raise serializers.ValidationError("Date column cannot contain time or timezone display format")


class TimeWithTimeZoneFormatValidator(AbstractDateTimeFormatValidator):

def validate(self, datetime_obj, display_format):
time_only_format = 'HH:mm:ssZZ'
time_str = arrow.get('2013-09-30T15:34:00.000-07:00').format(time_only_format)
parsed_time_str = arrow.get(time_str, time_only_format)
if parsed_time_str.date() != datetime_obj.date():
raise serializers.ValidationError("Time column cannot contain date display format")


class TimeWithoutTimeZoneFormatValidator(TimeWithTimeZoneFormatValidator):

def validate(self, datetime_obj, display_format):
if 'z' in display_format.lower():
raise serializers.ValidationError("Time without timezone column cannot contain timezone display format")
return super().validate(datetime_obj, display_format)


class DateDisplayOptionSerializer(OverrideRootPartialMixin, serializers.Serializer):
format = serializers.CharField(validators=[DateFormatValidator()])


class TimestampWithoutTimezoneDisplayOptionSerializer(OverrideRootPartialMixin, serializers.Serializer):
format = serializers.CharField(validators=[TimestampWithoutTimeZoneFormatValidator()])


class TimestampWithTimezoneDisplayOptionSerializer(OverrideRootPartialMixin, serializers.Serializer):
format = serializers.CharField(validators=[TimestampWithTimeZoneFormatValidator()])


class TimeWithTimezoneDisplayOptionSerializer(OverrideRootPartialMixin, serializers.Serializer):
format = serializers.CharField(validators=[TimeWithTimeZoneFormatValidator()])


class TimeWithoutTimezoneDisplayOptionSerializer(OverrideRootPartialMixin, serializers.Serializer):
format = serializers.CharField(validators=[TimeWithoutTimeZoneFormatValidator()])


class DisplayOptionsMappingSerializer(ReadWritePolymorphicSerializerMappingMixin, serializers.Serializer):
serializers_mapping = {MathesarTypeIdentifier.BOOLEAN.value: BooleanDisplayOptionSerializer,
MathesarTypeIdentifier.NUMBER.value: NumberDisplayOptionSerializer}
serializers_mapping = {
MathesarTypeIdentifier.BOOLEAN.value: BooleanDisplayOptionSerializer,
MathesarTypeIdentifier.NUMBER.value: NumberDisplayOptionSerializer,
('timestamp with time zone',
MathesarTypeIdentifier.DATETIME.value): TimestampWithTimezoneDisplayOptionSerializer,
('timestamp without time zone',
MathesarTypeIdentifier.DATETIME.value): TimestampWithoutTimezoneDisplayOptionSerializer,
('date', MathesarTypeIdentifier.DATETIME.value): DateDisplayOptionSerializer,
('time with time zone', MathesarTypeIdentifier.DATETIME.value): TimeWithTimezoneDisplayOptionSerializer,
('time without time zone', MathesarTypeIdentifier.DATETIME.value): TimeWithoutTimezoneDisplayOptionSerializer,
}

def get_mapping_field(self):
return get_mathesar_type_from_db_type(self.context[DISPLAY_OPTIONS_SERIALIZER_MAPPING_KEY])
mathesar_type = get_mathesar_type_from_db_type(self.context[DISPLAY_OPTIONS_SERIALIZER_MAPPING_KEY])
if mathesar_type == MathesarTypeIdentifier.DATETIME.value:
return self.context[DISPLAY_OPTIONS_SERIALIZER_MAPPING_KEY].lower(), mathesar_type
else:
return mathesar_type
2 changes: 1 addition & 1 deletion mathesar/reflection.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def reflect_columns_from_table(table):
defaults={'display_options': None})
if not created and column.display_options:
serializer = DisplayOptionsMappingSerializer(data=column.display_options,
context={DISPLAY_OPTIONS_SERIALIZER_MAPPING_KEY: str(column.type)})
context={DISPLAY_OPTIONS_SERIALIZER_MAPPING_KEY: str(column.plain_type)})
if not serializer.is_valid(False):
column.display_options = None
column.save()
Expand Down
18 changes: 16 additions & 2 deletions mathesar/tests/api/test_column_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import pytest
from unittest.mock import patch
from django.core.cache import cache
from sqlalchemy import Column, Integer, String, MetaData, select, Boolean
from sqlalchemy import Column, Integer, String, MetaData, select, Boolean, TIMESTAMP
from sqlalchemy import Table as SATable

from db.columns.operations.alter import alter_column_type
Expand Down Expand Up @@ -48,10 +48,12 @@ def column_test_table_with_service_layer_options(patent_schema):
Column("mycolumn0", Integer, primary_key=True),
Column("mycolumn1", Boolean),
Column("mycolumn2", Integer),
Column("mycolumn4", TIMESTAMP),
]
column_data_list = [{},
{'display_options': {'input': "dropdown", 'use_custom_labels': False}},
{'display_options': {"show_as_percentage": True, "locale": "en_US"}}]
{'display_options': {"show_as_percentage": True, "locale": "en_US"}},
{'display_options': {'format': 'YYYY-MM-DD hh:mm'}}]
db_table = SATable(
"anewtable",
MetaData(bind=engine),
Expand Down Expand Up @@ -236,6 +238,11 @@ def test_column_create_invalid_default(column_test_table, client):
("BOOLEAN", {"input": "checkbox", "custom_labels": {"TRUE": "yes", "FALSE": "no"}}),
("NUMERIC", {"show_as_percentage": True}),
("NUMERIC", {"show_as_percentage": True, "locale": "en_US"}),
("TIMESTAMP WITH TIME ZONE", {'format': 'YYYY-MM-DD hh:mm'}),
("TIMESTAMP WITHOUT TIME ZONE", {'format': 'YYYY-MM-DD hh:mm'}),
("TIME WITHOUT TIME ZONE", {'format': 'hh:mm'}),
("TIME WITH TIME ZONE", {'format': 'hh:mm Z'}),

]


Expand All @@ -261,6 +268,13 @@ def test_column_create_display_options(
("BOOLEAN", {"input": "invalid", "use_custom_columns": False}),
("BOOLEAN", {"input": "checkbox", "use_custom_columns": True, "custom_labels": {"yes": "yes", "1": "no"}}),
("NUMERIC", {"show_as_percentage": "wrong value type"}),
("TIMESTAMP WITH TIME ZONE", {'format': 'xyz'}),
("TIMESTAMP WITHOUT TIME ZONE", {'format': 'xyz'}),
("TIMESTAMP WITHOUT TIME ZONE", {'format': 'YYYY-MM-DD hh:mm Z'}),
("DATE", {'format': 'YYYY-MM-DD hh:mm Z'}),
("TIME WITH TIME ZONE", {'format': 'YYYY-MM-DD hh:mm Z'}),
("TIME WITHOUT TIME ZONE", {'format': 'YYYY-MM-DD hh:mm'}),
("TIME WITHOUT TIME ZONE", {'format': 'hh:mm Z'}),
]


Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
alembic==1.6.5
arrow==1.2.1
charset-normalizer==2.0.7
clevercsv==0.6.8
Django==3.1.14
Expand Down