├── LICENSE ├── README.rst ├── model_helpers.py └── tests ├── .coverage ├── __init__.py ├── how_to_run.txt ├── simple_app ├── manage.py ├── sample │ ├── __init__.py │ └── models.py └── simple_app │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── test_cached_property.py ├── test_choices.py ├── test_key_value_field.py └── test_upload_to.py /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 rewardz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | **Model helpers** are small collection of django functions and classes that make working with models easier. All functions here are compliant with pylint and has test cases with over 95% code coverage. This doc describe each of these helpers. 2 | 3 | upload_to_ 4 | Pass this function to your `FileField` as `upload_to` argument 5 | 6 | cached_model_property_ 7 | Decorate a model function with that decorator to cache function's result 8 | 9 | Choices_ 10 | A feature rich solution for implementing choice field 11 | 12 | KeyValueField_ 13 | A field that can store multiple key/value entries in a human readable form 14 | 15 | .. _upload_to: 16 | 17 | **model\_helpers.upload\_to** 18 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 19 | 20 | Pass ``model_helpers.upload_to`` as ``upload_to`` parameter for any FileField or ImageField. This will - by default - generate slugified version of the file name. By default each model get its own storage folder named after model's name. 21 | 22 | ``upload_to`` function also block files with certain harmful extensions like "php" or "py" from being uploaded. 23 | 24 | **Sample usage:** 25 | 26 | :: 27 | 28 | import model_helpers 29 | 30 | class Profile(models.model): 31 | name = CharField(max_length=100) 32 | picture = ImageField(upload_to=model_helpers.upload_to) 33 | 34 | uploaded images for this model will be stored in: ``media/Profile//``. 35 | 36 | **settings** 37 | 38 | settings for ``upload_to`` function should be placed in ``UPLOAD_TO_OPTIONS`` inside your *settings.py* file These are the default settings 39 | 40 | :: 41 | 42 | settings.UPLOAD_TO_OPTIONS = { 43 | "black_listed_extensions": ["php", "html", "htm", "js", "vbs", "py", "pyc", "asp", "aspx", "pl"], 44 | "max_filename_length": 40, 45 | "file_name_template": "{model_name}/%Y/{filename}.{extension}" 46 | } 47 | 48 | - ``black_listed_extensions`` prevent any file with any of these extensions from being saved. 49 | - ``max_filename_length`` trim filename if it exceeds certain length to mitigate DB errors when user upload long filename 50 | - ``file_name_template`` controls where the file should be saved. 51 | 52 | **specifying ``file_name_template``** 53 | 54 | ``file_name_template`` pass your string to strftime() function; ``'%Y'`` in the example above is the four-digit year. other accepted variables are: 55 | 56 | - ``model_name``: name of the model which the file is being uploaded for. 57 | - ``filename``: name of the file - without extension - after it has been processed by ``upload_to`` (trimmed and slugified) 58 | - ``extension``: file's extension 59 | - ``instance``: the model instance passed to ``upload_to`` function 60 | 61 | For example to save uploaded files to a directory like this 62 | 63 | :: 64 | 65 | model name/current year/current month/instance's name(dot)file's extension 66 | 67 | you do 68 | 69 | :: 70 | 71 | UPLOAD_TO_OPTIONS = {"file_name_template": "{model_name}/%Y/%m/{instance.name}.{extension}" } 72 | 73 | **customizing ``upload_to`` per model** 74 | 75 | If you want to have different ``upload_to`` options for different models, use ``UploadTo`` class instead. For example to have ``ImageField`` that allow all file extensions, You can do this: 76 | 77 | :: 78 | 79 | my_image = models.ImageField(upload_to=models_helper.UploadTo(black_listed_extensions=[]) 80 | 81 | ``UploadTo`` class accepts all ``upload_to`` settings documented above. You can also inherit from this class if you want to have very custom file naming schema (like if you want file name be based on its md5sum) 82 | 83 | .. _cached_model_property: 84 | 85 | cached_model_property decorator 86 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 87 | 88 | ``cached_model_property`` is a decorator for model functions that takes no arguments. The decorator convert the function into a property that support caching out of the box 89 | 90 | **Note**: ``cached_model_property`` is totally different from django's ``cached_property`` the later is not true caching but rather memorizing function's return value. 91 | 92 | **Sample usage:** 93 | 94 | :: 95 | 96 | class Team(models.Model): 97 | @cached_model_property 98 | def points(self): 99 | # Do complex DB queries 100 | return result 101 | 102 | @cached_model_property(readonly=False) 103 | def editable_points(self): 104 | # get result 105 | return result 106 | 107 | @cached_model_property(cache_timeout=1) 108 | def one_second_cache(self): 109 | # get result 110 | return result 111 | 112 | Now try 113 | 114 | :: 115 | 116 | team = Team.objects.first() 117 | 118 | - ``team.points`` <-- complex DB queries will happen, result will be returned 119 | - ``team.points`` <-- this time result is returned from cache (points function is not called 120 | - ``del team.points`` <-- points value has been removed from cache 121 | - ``team.points`` <-- complex DB queries will happen, result will be returned 122 | 123 | **How does it work?**: first time the decorator store the function output in the cache with ``key = "__"`` so if you have two models with same name, or have model that provide no primary key you can't use this decorator. 124 | 125 | set ``readonly`` parameter to ``False`` to make the property writeable 126 | 127 | ``team.editable_points = 88`` 128 | 129 | In this case the assigned value will replace the value stored in the cache 130 | 131 | ``team.editable_points`` returns 88 132 | 133 | I personally don't use the writable cached property option but might be useful to someone else 134 | 135 | .. _Choices: 136 | 137 | Choices class (inspired by `Django Choices `_. ) 138 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 139 | 140 | Dealing with Django's ``choices`` attribute is a pain. Here is a proper way of implementing choice field in Django 141 | 142 | :: 143 | 144 | class Student(models.Model): 145 | FRESHMAN = 'FR' 146 | SOPHOMORE = 'SO' 147 | JUNIOR = 'JR' 148 | SENIOR = 'SR' 149 | YEAR_IN_SCHOOL_CHOICES = ( 150 | (FRESHMAN, 'Freshman'), 151 | (SOPHOMORE, 'Sophomore'), 152 | (JUNIOR, 'Junior'), 153 | (SENIOR, 'Senior'), 154 | ) 155 | year_in_school = models.CharField( 156 | max_length=2, 157 | choices=YEAR_IN_SCHOOL_CHOICES, 158 | default=FRESHMAN) 159 | 160 | Then you can do 161 | 162 | :: 163 | 164 | student = Student.objects.first() 165 | if student.year_in_school == Student.SENIOR: 166 | # do some senior stuff 167 | 168 | With Choices class this becomes 169 | 170 | :: 171 | 172 | YEAR_IN_SCHOOL_CHOICES = Choices({ 173 | "freshman": "FR", 174 | "sophomore": "SO", 175 | "junior": "JR", 176 | "Senior": "SR" 177 | }) 178 | 179 | 180 | class Student(models.Model): 181 | year_in_school = models.CharField( 182 | max_length=2, 183 | choices=YEAR_IN_SCHOOL_CHOICES(), 184 | default=YEAR_IN_SCHOOL_CHOICES.freshman) 185 | 186 | Then you can do 187 | 188 | :: 189 | 190 | student = Student.objects.first() 191 | if student.year_in_school == YEAR_IN_SCHOOL_CHOICES.senior: 192 | # do some senior stuff 193 | 194 | ``YEAR_IN_SCHOOL_CHOICES`` is a readonly OrderedDict and you can treat it as such. for example: ``YEAR_IN_SCHOOL_CHOICES.keys()`` or ``YEAR_IN_SCHOOL_CHOICES.iteritems()`` 195 | 196 | ``Choices`` class is more flexible because it allow you to specify 3 values. choice name, choice db value, choice display name. The example above can be better written like that 197 | 198 | :: 199 | 200 | YEAR_IN_SCHOOL_CHOICES = Choices({ 201 | "freshman": {"id": 0, "display": "New comer"}, 202 | "sophomore": 1, 203 | "junior": 2, 204 | "Senior": 3 205 | }, order_by="id") 206 | 207 | 208 | class Student(models.Model): 209 | year_in_school = models.SmalllIntegerField( 210 | choices=YEAR_IN_SCHOOL_CHOICES(), 211 | default=YEAR_IN_SCHOOL_CHOICES.freshman) 212 | 213 | Then you can do something like this 214 | 215 | :: 216 | 217 | Student.objects.filter( 218 | year_in_school__gt=YEAR_IN_SCHOOL_CHOICES.sophomore) 219 | 220 | To return all students in grades higher than Sophomore 221 | 222 | - A choice can be defined as key/value ``"sophomore": 1`` in which case display name will be code name capitalized ``"Sophomore"`` and will be saved in DB as number ``1`` 223 | - A choice can be fully defined as key/dict ``"freshman": {"id": 0, "display": "New comer"}`` in which case display name will be ``"New comer"`` and id will be ``0`` 224 | 225 | Defining extra keys to use in your code. 226 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 227 | 228 | As mentioned before ``Choices`` can be treated as an OrderedDictionary and so you should feel free to use the free functionality, for example adding extra keys 229 | 230 | :: 231 | 232 | AVAILABLE_SETTINGS = Choices({ 233 | "max_page_width": {"id": 0, "display": "Maximum page width in pixels", "default": 100}) 234 | 235 | then in your code you can do 236 | 237 | :: 238 | 239 | settings = Settings.objects.filter(name=AVAILABLE_SETTINGS.max_page_width).first() 240 | if settings: 241 | return settings.value 242 | return AVAILABLE_SETTINGS["max_page_width"]["default"] 243 | 244 | Ordering your ``Choices`` 245 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 246 | 247 | Assuming you have a big list of choices you might prefer to ask Choices class to order them for you. 248 | 249 | **Example:** 250 | 251 | :: 252 | 253 | Choices({ 254 | "usa": {"display": "United States", "id": 0}, 255 | "egypt": 1, 256 | "uk": {"display": "United Kingdom", "id": 2}, 257 | "ua": {"display": "Ukraine", "id": 3} 258 | }, order_by="display") 259 | 260 | The fields will be in the order "Egypt", "Ukraine", "United Kingdom", "United States". 261 | 262 | ``order_by="id"`` will order the list by id 263 | 264 | If you don't want any sort of ordering then set ``order_by=None`` and in this case its better that you pass your choices as tuple of dictionaries to maintain order 265 | 266 | :: 267 | 268 | Choices(( 269 | ("uk", {"display": "United Kingdom", "id": 2), 270 | ("usa", {"display": "United States", "id": 0), 271 | ("egypt", 1), 272 | ("ua": {"display": "Ukraine", "id": 3}) 273 | ), order_by=None) 274 | 275 | **Note:** By default choices are ordered by display name 276 | 277 | Useful functions of ``Choices`` class 278 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 279 | 280 | - ``get_display_name``: given choice id, return the display name of that id. same as model's ``get__display()`` 281 | - ``get_code_name``: Given choice id same as ``get_display_name`` but return code name 282 | - ``get_value``: Given choice id, return value of any key defined inside choice entry 283 | 284 | **Example:** 285 | 286 | :: 287 | 288 | CHOICES_EXAMPLE = Choices({"my_key": {"id": 0, "display": "Display Of My Key", "additional_key": 1234}) 289 | >>> CHOICES_EXAMPLE.get_display_name(0) 290 | "Display Of My Key" 291 | >>> CHOICES_EXAMPLE.get_code_name(0) 292 | "my_key" 293 | >>> CHOICES_EXAMPLE.get_value(0, "additional_key") 294 | 1234 295 | 296 | .. _KeyValueField: 297 | 298 | **model\_helpers.KeyValueField** 299 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 300 | 301 | Sometimes you need to have a simple key/value field. most developers would rely on ``JsonField`` which is good for some use cases but people using django admin may not like to modify json object that look like this 302 | 303 | :: 304 | 305 | {"key1": "value of some sort", "key2": "value containing \" character"} 306 | 307 | ``KeyValueField`` serialize objects in a more readable way. the dictionary above would be stored and displayed like this. 308 | 309 | :: 310 | 311 | key1 = value of some sort 312 | key2 = value containing " character 313 | 314 | That's it. For you as a developer you will access your ``KeyValueField`` as a dictionary. 315 | 316 | **Example**: 317 | 318 | :: 319 | 320 | class MyModel(models.Model): 321 | options = KeyValueField(separator=":") 322 | 323 | >> my_model.options = "key1 : val1 \n key2 : val2" 324 | >> my_model.options 325 | {"key1": "val1", "key2": "val2"} 326 | >>> str(my_model.options) 327 | "key1 : val1 \n key2 : val2" 328 | 329 | You can find more examples in the test file ``tests/test_key_value_field.py`` 330 | 331 | **``KeyValueField`` is NOT good for:** 332 | 333 | - Maintain original value's datatype. all values are converted to unicode strings 334 | - Store a multiline value 335 | -------------------------------------------------------------------------------- /model_helpers.py: -------------------------------------------------------------------------------- 1 | import six 2 | 3 | from django.core.exceptions import ValidationError 4 | from os import path as fs_path 5 | from time import strftime 6 | from django.utils.text import slugify 7 | from django.utils.translation import ugettext as _ 8 | from django.core.cache import cache 9 | from django.conf import settings 10 | from django.db import models 11 | from collections import OrderedDict 12 | 13 | try: 14 | from django.utils.deconstruct import deconstructible 15 | except ImportError: 16 | # for older versions of django, define a no-op decorator instead. 17 | def deconstructible(old_class): 18 | return old_class 19 | 20 | UPLOAD_TO_OPTIONS = { 21 | "black_listed_extensions": ["php", "html", "htm", "js", "vbs", "py", "pyc", "asp", "aspx", "pl"], 22 | "max_filename_length": 40, 23 | "file_name_template": "{model_name}/%Y/{filename}.{extension}" 24 | } 25 | 26 | 27 | @deconstructible 28 | class UploadTo(object): 29 | """ 30 | An instance of this class is passed as "upload_to" parameter for any FileField or ImageField 31 | It ensures file name is less than "max_filename_length" char also slugify the filename and finally provide simple 32 | protection against uploading some harmful files like (php or python files) 33 | 34 | File is saved in a folder called //file_name.ext 35 | example: User/2015/profile_pic.jpg 36 | """ 37 | 38 | def __init__(self, **kwargs): 39 | """ 40 | :param kwargs: You can override any of the default options by passing it as keyword argument to this function 41 | :return: 42 | """ 43 | self.options = UPLOAD_TO_OPTIONS.copy() 44 | if hasattr(settings, "UPLOAD_TO_OPTIONS"): 45 | self.options.update(settings.UPLOAD_TO_OPTIONS) 46 | self.options.update(kwargs) 47 | 48 | @staticmethod 49 | def get_file_info(full_filename): 50 | filename = fs_path.basename(full_filename).lower() 51 | filename, file_ext = filename.rsplit(".", 1) 52 | return { 53 | "filename": filename, 54 | "extension": file_ext, 55 | "full_filename": full_filename 56 | } 57 | 58 | def validate_file_info(self, file_info): 59 | file_ext = file_info["extension"] 60 | if file_ext in self.options["black_listed_extensions"]: 61 | raise ValueError("File extension '%s' is not allowed" % file_ext) 62 | 63 | def generate_file_name(self, instance, file_info): 64 | model_name = instance.__class__.__name__ 65 | filename = file_info["filename"] 66 | max_len = self.options["max_filename_length"] 67 | file_info["filename"] = slugify(filename)[:max_len] 68 | 69 | return strftime(self.options["file_name_template"]).format( 70 | model_name=model_name, 71 | instance=instance, 72 | **file_info 73 | ) 74 | 75 | def __call__(self, instance, full_filename): 76 | """ 77 | :param instance: model instance which the file is uploaded for 78 | :param full_filename: filename including its path 79 | :return: string 80 | """ 81 | full_filename = six.text_type(full_filename) 82 | file_info = self.get_file_info(full_filename) 83 | self.validate_file_info(file_info) 84 | return self.generate_file_name(instance, file_info) 85 | 86 | 87 | # Shortcut for UploadTo class 88 | def upload_to(instance, full_filename): 89 | upload_to_obj = UploadTo() 90 | return upload_to_obj(instance, full_filename) 91 | 92 | 93 | def cached_model_property(model_method=None, readonly=True, cache_timeout=None): 94 | """ 95 | cached_model_property is a decorator for model functions that takes no arguments 96 | The function is converted into a property that support caching out of the box 97 | 98 | :param readonly: set readonly parameter False to make the property writeable 99 | :type readonly: bool 100 | :param cache_timeout: number of seconds before cache expires 101 | :type cache_timeout: int 102 | 103 | Sample usage: 104 | 105 | class Team(models.Model): 106 | 107 | @cached_model_property 108 | def points(self): 109 | # Do complex DB queries 110 | return result 111 | 112 | @cached_model_property(readonly=False) 113 | def editable_points(self): 114 | # get result 115 | return result 116 | 117 | Now try 118 | team = Team.objects.first() 119 | team.points <-- complex DB queries will happen, result will be returned 120 | team.points <-- this time result is returned from cache (points function is not called at all! 121 | del team.points <-- points value has been removed from cache 122 | team.points <-- complex DB queries will happen, result will be returned 123 | 124 | set readonly parameter False to make the property writeable 125 | team.editable_points = 88 126 | in this case the assigned value will replace the value stored in the cache 127 | team.editable_points 128 | returns 88 129 | """ 130 | 131 | def func(f): 132 | def _get_cache_key(obj): 133 | """ 134 | :type obj: django.db.models.Model 135 | :rtype: six.string_types 136 | """ 137 | # getattr(obj, "_meta") is same as obj._meta but avoid the warning about accessing protected property 138 | model_name = getattr(obj, "_meta").db_table 139 | method_name = f.__name__ 140 | return "%s.%s.%s" % (model_name, obj.pk, method_name) 141 | 142 | def get_x(obj): 143 | # Try to get the cache key for that method 144 | cache_key = _get_cache_key(obj) 145 | result = cache.get(cache_key) 146 | # If not cached, call the actual method and cache the result 147 | if result is None: 148 | result = f(obj) 149 | set_x(obj, result) 150 | return result 151 | 152 | def del_x(obj): 153 | """ 154 | Remove that property from the cache 155 | :param obj: 156 | :return: None 157 | """ 158 | cache_key = _get_cache_key(obj) 159 | # Remove that key from the cache 160 | cache.delete(cache_key) 161 | 162 | def set_x(obj, value): 163 | """ 164 | Set the cache value of that property 165 | :param obj: 166 | :return: None 167 | """ 168 | cache_key = _get_cache_key(obj) 169 | # Save that key in the cache 170 | if cache_timeout is None: 171 | cache.set(cache_key, value) 172 | else: 173 | cache.set(cache_key, value, cache_timeout) 174 | 175 | if readonly: 176 | return property(fget=get_x, fdel=del_x) 177 | else: 178 | return property(fget=get_x, fset=set_x, fdel=del_x) 179 | 180 | # model_method is passed when using @cached_model_property 181 | if model_method: 182 | return func(model_method) 183 | # model_method is not passed when using @cached_model_property(readonly=True) or even @cached_model_property() 184 | return func 185 | 186 | 187 | # noinspection PyOldStyleClasses 188 | class Choices(OrderedDict): 189 | """ 190 | Offer a cleaner way for django choices field 191 | 192 | Usage: 193 | 194 | ** define a constant ** 195 | ANIMAL_TYPES = Choices( 196 | [ 197 | {"insect": 1, 198 | {"mammal": {"id": 2}, # same as {"mammal": 2} 199 | {"none": {"id": None, "display": "Not Animal"}, 200 | ]) 201 | 202 | ** Inside your model class ** 203 | 204 | animal_type = models.IntegerField(choices=ANIMAL_TYPES(), null=True) 205 | 206 | output of ANIMAL_TYPES() is django choice list ordered by display name: 207 | [(1, 'Insect'), (2, 'Mammal'), (None, 'Not Animal')] 208 | 209 | ** Using the new model ** 210 | animal = Animals.objects.first() 211 | if animal.animal_type == ANIMAL_TYPES.insect: 212 | # do the insect related code 213 | 214 | """ 215 | 216 | # always True except during the execution of__init__() and update() methods 217 | _read_only = True 218 | # cache for mapping between choice id and choice dictionary (populated on demand) 219 | _choices_id = None 220 | 221 | def __init__(self, choices, order_by="display"): 222 | """ 223 | 224 | :param choices: dictionary of dictionary . ex: {'choice1': {'id':1, 'display': 'Code One'}, ...} 225 | display key is optional. if not provided its assumed to be dict_key.replace("_", " ").capitalize() 226 | :type choices: Choices | OrderedDict | dict | tuple | list 227 | :param order_by: Whether generated Django choice list should be ordered (valid options "id", "display", None) 228 | :type order_by: str | None 229 | """ 230 | self._read_only = False 231 | 232 | # Initialize parent dict with the choices provided by the user 233 | super(Choices, self).__init__(choices) 234 | self._choices = _choices = [] 235 | self._order_by = order_by 236 | 237 | if not choices: 238 | return 239 | # choice_ids are used to validate an id is not used more than once 240 | choice_ids = set() 241 | 242 | for choice_code, choice_options in self.items(): 243 | if not issubclass(choice_options.__class__, dict): 244 | # in case passing {"insect": 1} assume 1 is the id 245 | choice_options = {"id": choice_options} 246 | self[choice_code] = choice_options 247 | 248 | choice_id = choice_options["id"] 249 | choice_ids.add(choice_id) 250 | # End of validation 251 | if "display" not in choice_options: 252 | choice_options["display"] = choice_code.replace("_", " ").capitalize() 253 | display = choice_options["display"] 254 | _choices.append((choice_id, _(display))) 255 | # Sort by display name 256 | if order_by == "display": 257 | _choices.sort(key=lambda x: x[1]) 258 | elif order_by == "id": 259 | _choices.sort(key=lambda x: x[0]) 260 | 261 | self._read_only = True 262 | 263 | def get_display_name(self, choice_id): 264 | """ 265 | Return translated display name of certain choice. 266 | same same model's get__display() 267 | :param choice_id: choice id 268 | :rtype: str 269 | """ 270 | return self.get_value(choice_id, "display") 271 | 272 | def get_value(self, choice_id, choice_key, raise_exception=True): 273 | """ 274 | Finds a choice with id and return value of key 275 | 276 | :param choice_id: the db value of the choice in question 277 | :param choice_key: the key inside choice dictionary in which you want to get value of 278 | :param raise_exception: if True, KeyError exception will be raised if the key wasn't found 279 | :return: whatever stored in that choice key is returned, 280 | if key not found and raise_exception=False then None is returned 281 | """ 282 | if self._choices_id is None: 283 | self._choices_id = {item["id"]: (key, item) for key, item in six.iteritems(self)} 284 | 285 | choice_name, choice = self._choices_id[choice_id] 286 | if choice_key is None: 287 | return choice_name 288 | elif raise_exception: 289 | return choice[choice_key] 290 | else: 291 | return choice.get(choice_key) 292 | 293 | def get_code_name(self, choice_id): 294 | """ 295 | Return code name of certain choice 296 | :param choice_id: choice id 297 | :rtype: str 298 | """ 299 | return self.get_value(choice_id, choice_key=None) 300 | 301 | def __getattr__(self, attr_name): 302 | if attr_name in self: 303 | return self[attr_name]["id"] 304 | raise AttributeError("Attribute %s is not part of %s class" % (attr_name, self.__class__.__name__)) 305 | 306 | def __call__(self): 307 | """ 308 | :return: list of choices 309 | :rtype: list 310 | """ 311 | return self._choices 312 | 313 | def __setattr__(self, attr, *args): 314 | if self._read_only and attr in self: 315 | raise TypeError("Choices are constants and can't be modified") 316 | super(Choices, self).__setattr__(attr, *args) 317 | 318 | def __setitem__(self, *args): 319 | if self._read_only: 320 | raise TypeError("Choices are constants and can't be modified") 321 | super(Choices, self).__setitem__(*args) 322 | 323 | def __dir__(self): 324 | return list(self.keys()) + dir(self.__class__) 325 | 326 | def copy(self): 327 | new_self = Choices({}, order_by=self._order_by) 328 | new_self.update(self) 329 | return new_self 330 | 331 | def update(self, new_data=None, **kwargs): 332 | """ 333 | :type new_data: Choices | OrderedDict | dict | tuple | list 334 | """ 335 | if self._read_only: 336 | raise TypeError("Choices are constants and can't be modified") 337 | 338 | if not new_data: 339 | new_data = kwargs 340 | 341 | if not isinstance(new_data, Choices): 342 | new_data = Choices(new_data) 343 | assert isinstance(new_data, Choices) 344 | 345 | common_keys = set(new_data.keys()) & set(self.keys()) 346 | if common_keys: 347 | raise ValueError("The following keys exist in both instances %s" % ", ".join(common_keys)) 348 | 349 | self._choices += (new_data()) 350 | self._choices_id = None 351 | 352 | super(Choices, self).update(new_data) 353 | 354 | def __enter__(self): 355 | return self 356 | 357 | def __exit__(self, *args, **kwargs): 358 | self._read_only = True 359 | 360 | def __add__(self, other): 361 | self._read_only = False 362 | with self.copy() as result: 363 | result.update(other) 364 | self._read_only = True 365 | return result 366 | 367 | 368 | class KeyValueContainer(dict): 369 | 370 | def __init__(self, seq=None, separator="=", **kwargs): 371 | super(KeyValueContainer, self).__init__() 372 | self.sep = separator 373 | if isinstance(seq, six.string_types): 374 | seq = self._parse_string(seq) 375 | if seq is not None: 376 | seq = dict(seq) 377 | kwargs.update(seq) 378 | 379 | for key, value in six.iteritems(kwargs): 380 | self.__setitem__(key, value) 381 | 382 | def __str__(self): 383 | result = [] 384 | for key, val in six.iteritems(self): 385 | result.append(u"%s %s %s" % (key, self.sep, val)) 386 | return u"\n".join(result) + "\n" 387 | 388 | def __setitem__(self, key, item): 389 | if item is None: 390 | item = "" 391 | else: 392 | item = six.text_type(item) 393 | super(KeyValueContainer, self).__setitem__(key, item) 394 | 395 | def __unicode__(self): 396 | return self.__str__() 397 | 398 | def _parse_string(self, value): 399 | result = {} 400 | if not value: 401 | return result 402 | 403 | for line in value.split("\n"): 404 | line = line.strip() 405 | if not line: 406 | continue 407 | if self.sep not in line: 408 | raise ValueError(_("Invalid syntax in line %s\nExpected: key %s value") % (repr(line), self.sep)) 409 | key, value = [val.strip() for val in line.split(self.sep, 1)] 410 | result[key] = value 411 | 412 | return result 413 | 414 | 415 | class KeyValueField(models.TextField): 416 | """ 417 | Basically a way to store configuration in DB and have it returned as dictionary. 418 | Simple key/value store 419 | data stored as 420 | key = value 421 | default separator is "=" but it can be customized 422 | 423 | sample usage 424 | 425 | class MyModel(models.Model): 426 | options = KeyValueField(separator=":") 427 | 428 | >> my_model.options = "key1 : val1 \n key2 : val2" 429 | >> my_model.clean_fields() 430 | >> my_model.options 431 | {"key1": "val1", "key2": "val2"} 432 | """ 433 | description = _("Key/Value dictionary field") 434 | empty_values = (None,) 435 | 436 | def __init__(self, separator="=", *args, **kwargs): 437 | self.separator = separator 438 | super(KeyValueField, self).__init__(*args, **kwargs) 439 | 440 | def contribute_to_class(self, cls, name, **kwargs): 441 | super(KeyValueField, self).contribute_to_class(cls, name, **kwargs) 442 | setattr(cls, name, property(fget=self.get_value, fset=self.set_value)) 443 | 444 | def set_value(self, obj, value): 445 | if isinstance(value, six.string_types): 446 | value = self.from_db_value(value) 447 | elif not isinstance(value, KeyValueContainer): 448 | value = KeyValueContainer(value) 449 | obj.__dict__[self.name] = value 450 | 451 | def get_value(self, obj): 452 | return obj.__dict__[self.name] 453 | 454 | def from_db_value(self, value, *args, **kwargs): 455 | try: 456 | return KeyValueContainer(value, separator=self.separator) 457 | except ValueError as e: 458 | raise ValidationError(e) 459 | 460 | def get_prep_value(self, value): 461 | if value is None: 462 | return "" 463 | return six.text_type(value) 464 | 465 | def deconstruct(self): 466 | name, path, args, kwargs = super(KeyValueField, self).deconstruct() 467 | if self.separator != "=": 468 | kwargs["separator"] = self.separator 469 | return name, path, args, kwargs 470 | -------------------------------------------------------------------------------- /tests/.coverage: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rewardz/django_model_helpers/4206faf1cb11a911cb65e0224a08ec062ba8dac6/tests/.coverage -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import django 4 | from django.core.management import call_command, CommandError 5 | 6 | app_path = os.path.join( 7 | os.path.dirname(__file__), 8 | "simple_app" 9 | ) 10 | 11 | sys.path.insert(0, app_path) 12 | os.environ["DJANGO_SETTINGS_MODULE"] = "simple_app.settings" 13 | try: 14 | django.setup() 15 | except AttributeError: 16 | from django.db.models.loading import get_models 17 | get_models() 18 | 19 | 20 | try: 21 | call_command("syncdb", interactive=False, verbosity=0) 22 | except CommandError: 23 | # Django >= 1.9 24 | call_command("migrate", "--run-syncdb") 25 | 26 | call_command("createcachetable", "cache") 27 | -------------------------------------------------------------------------------- /tests/how_to_run.txt: -------------------------------------------------------------------------------- 1 | If you are in the root directory of the package, you can run. 2 | 3 | pip install nose 4 | nosetests tests 5 | 6 | -------------------------------------------------------------------------------- /tests/simple_app/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "simple_app.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /tests/simple_app/sample/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rewardz/django_model_helpers/4206faf1cb11a911cb65e0224a08ec062ba8dac6/tests/simple_app/sample/__init__.py -------------------------------------------------------------------------------- /tests/simple_app/sample/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.encoding import python_2_unicode_compatible 3 | from model_helpers import cached_model_property, KeyValueField 4 | 5 | 6 | class Team(models.Model): 7 | name = models.CharField(max_length=100) 8 | options = KeyValueField(default="", blank=True, 9 | help_text="Set options using key=value format. i.e. password=123") 10 | counter = 0 11 | 12 | def get_counter(self): 13 | self.counter += 1 14 | return self.counter 15 | 16 | @cached_model_property 17 | def cached_counter(self): 18 | return self.get_counter() 19 | 20 | @cached_model_property(readonly=False) 21 | def writable_cached_counter(self): 22 | return self.get_counter() 23 | 24 | @cached_model_property(cache_timeout=1) 25 | def one_sec_cache(self): 26 | self.counter += 1 27 | return self.counter 28 | 29 | -------------------------------------------------------------------------------- /tests/simple_app/simple_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rewardz/django_model_helpers/4206faf1cb11a911cb65e0224a08ec062ba8dac6/tests/simple_app/simple_app/__init__.py -------------------------------------------------------------------------------- /tests/simple_app/simple_app/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for simple_app project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.8.3. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.8/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.8/ref/settings/ 11 | """ 12 | 13 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 14 | import os 15 | 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'xj#n!k9!7lgce4yem@h9g%jpg_cg&4!&_eh6gknic_b%e$yndk' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = ( 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'sample' 39 | ) 40 | 41 | MIDDLEWARE_CLASSES = ( 42 | 'django.contrib.sessions.middleware.SessionMiddleware', 43 | 'django.middleware.common.CommonMiddleware', 44 | 'django.middleware.csrf.CsrfViewMiddleware', 45 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 46 | 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 47 | 'django.contrib.messages.middleware.MessageMiddleware', 48 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 49 | 'django.middleware.security.SecurityMiddleware', 50 | ) 51 | 52 | ROOT_URLCONF = 'simple_app.urls' 53 | 54 | TEMPLATES = [ 55 | { 56 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 57 | 'DIRS': [], 58 | 'APP_DIRS': True, 59 | 'OPTIONS': { 60 | 'context_processors': [ 61 | 'django.template.context_processors.debug', 62 | 'django.template.context_processors.request', 63 | 'django.contrib.auth.context_processors.auth', 64 | 'django.contrib.messages.context_processors.messages', 65 | ], 66 | }, 67 | }, 68 | ] 69 | 70 | WSGI_APPLICATION = 'simple_app.wsgi.application' 71 | 72 | 73 | # Database 74 | # https://docs.djangoproject.com/en/1.8/ref/settings/#databases 75 | 76 | DATABASES = { 77 | 'default': { 78 | 'ENGINE': 'django.db.backends.sqlite3', 79 | 'NAME': ':memory:', 80 | } 81 | } 82 | 83 | CACHES = { 84 | 'default': { 85 | 'BACKEND': 'django.core.cache.backends.db.DatabaseCache', 86 | 'LOCATION': 'cache', 87 | 'TIMEOUT': 3 # Every 3 seconds 88 | } 89 | } 90 | 91 | LOGGING = { 92 | 'version': 1, 93 | 'disable_existing_loggers': True, 94 | '': { 95 | 'handlers': [], 96 | 'level': 'DEBUG', 97 | 'propagate': False, 98 | }, 99 | } 100 | -------------------------------------------------------------------------------- /tests/simple_app/simple_app/urls.py: -------------------------------------------------------------------------------- 1 | """simple_app URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.8/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Add an import: from blog import urls as blog_urls 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include(blog_urls)) 15 | """ 16 | from django.conf.urls import include, url 17 | from django.contrib import admin 18 | 19 | urlpatterns = [ 20 | url(r'^admin/', include(admin.site.urls)), 21 | ] 22 | -------------------------------------------------------------------------------- /tests/simple_app/simple_app/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for simple_app project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "simple_app.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /tests/test_cached_property.py: -------------------------------------------------------------------------------- 1 | from nose import tools 2 | from time import sleep 3 | from sample.models import Team 4 | 5 | 6 | def test_cached_property(): 7 | 8 | team1 = Team.objects.create(name="Team1") 9 | team2 = Team.objects.create(name="Team2") 10 | # Make sure the counter is incrementing 11 | tools.assert_equal(team1.get_counter(), 1) 12 | tools.assert_equal(team1.get_counter(), 2) 13 | tools.assert_equal(team1.get_counter(), 3) 14 | # Test caching 15 | tools.assert_equal(team1.cached_counter, 4) 16 | tools.assert_equal(team1.cached_counter, 4) 17 | tools.assert_equal(team1.cached_counter, 4) 18 | # Make sure caching is still working even if I made new instance 19 | team1_new = Team.objects.get(pk=team1.pk) 20 | tools.assert_equal(team1_new.cached_counter, 4) 21 | tools.assert_equal(team1_new.cached_counter, 4) 22 | # Make sure team2 is not affected by any of this 23 | team2.get_counter() 24 | tools.assert_equal(team2.cached_counter, 2) 25 | tools.assert_equal(team2.cached_counter, 2) 26 | # Reset caching, let get_counter get called again for both team1 and team1_new 27 | del team1_new.cached_counter 28 | tools.assert_equal(team1.cached_counter, 5) 29 | tools.assert_equal(team1_new.cached_counter, 5) 30 | del team1_new.cached_counter 31 | tools.assert_equal(team1_new.cached_counter, 1) 32 | # team2's caching shouldn't be affected though 33 | tools.assert_equal(team2.cached_counter, 2) 34 | 35 | 36 | def test_writeable_cached_property(): 37 | team1 = Team.objects.create(name="Team1") 38 | team2 = Team.objects.create(name="Team2") 39 | 40 | tools.assert_equal(team1.writable_cached_counter, 1) 41 | tools.assert_equal(team2.writable_cached_counter, 1) 42 | 43 | team1.writable_cached_counter = 77 44 | tools.assert_equal(team1.writable_cached_counter, 77) 45 | tools.assert_equal(Team.objects.get(pk=team1.pk).writable_cached_counter, 77) 46 | tools.assert_equal(team2.writable_cached_counter, 1) 47 | tools.assert_equal(Team.objects.get(pk=team2.pk).writable_cached_counter, 1) 48 | 49 | # Removing the cache should remove the weird effect 50 | del team1.writable_cached_counter 51 | tools.assert_equal(team1.writable_cached_counter, 2) 52 | tools.assert_equal(team2.writable_cached_counter, 1) 53 | del team2.writable_cached_counter 54 | tools.assert_equal(team2.writable_cached_counter, 2) 55 | 56 | 57 | def test_cache_timeout(): 58 | team = Team(name="Team1") 59 | tools.assert_equal(team.one_sec_cache, 1) 60 | tools.assert_equal(team.one_sec_cache, 1) 61 | tools.assert_equal(team.one_sec_cache, 1) 62 | sleep(2) 63 | tools.assert_equal(team.one_sec_cache, 2) 64 | tools.assert_equal(team.one_sec_cache, 2) 65 | 66 | # test default cache timeout (we set it to 3 seconds) 67 | team = Team(name="Team1") 68 | del team.cached_counter 69 | tools.assert_equal(team.cached_counter, 1) 70 | tools.assert_equal(team.cached_counter, 1) 71 | sleep(4) 72 | tools.assert_equal(team.cached_counter, 2) 73 | -------------------------------------------------------------------------------- /tests/test_choices.py: -------------------------------------------------------------------------------- 1 | from nose import tools 2 | import model_helpers 3 | 4 | # Disable translation, We don't offer testing for translation functionality 5 | model_helpers.ugettext_lazy = lambda x: x 6 | model_helpers._ = lambda x: x 7 | 8 | 9 | def test_choices_output(): 10 | choices = model_helpers.Choices({ 11 | "choice1": 1, 12 | "choice2": {"id": 2}, 13 | "choice__xx": {"id": 3, "display": "Choice_XX"}, 14 | "choice3": {"id": 3, "display": "Choice_3"} 15 | }) 16 | tools.assert_equal(choices(), [ 17 | (1, "Choice1"), 18 | (2, "Choice2"), 19 | (3, "Choice_3"), 20 | (3, "Choice_XX")]) 21 | 22 | 23 | def test_choices_order(): 24 | # Order by "display" (default) 25 | choices = model_helpers.Choices([ 26 | ("choice1", 1), 27 | ("choice2", {"id": 2}), 28 | ("choice3", {"id": 3, "display": "A_Choice_3"}), 29 | ]) 30 | tools.assert_equal(choices(), [ 31 | (3, "A_Choice_3"), 32 | (1, "Choice1"), 33 | (2, "Choice2")]) 34 | # Order by "id" 35 | choices = model_helpers.Choices([ 36 | ("choice1", 1), 37 | ("choice3", {"id": 3, "display": "A_Choice_3"}), 38 | ("choice2", {"id": 2}), 39 | ], order_by="id") 40 | tools.assert_equal(choices(), [ 41 | (1, "Choice1"), 42 | (2, "Choice2"), 43 | (3, "A_Choice_3")]) 44 | # Disable ordering 45 | choices = model_helpers.Choices([ 46 | ("choice1", 1), 47 | ("choice3", {"id": 3, "display": "A_Choice_3"}), 48 | ("choice2", {"id": 2}), 49 | ], order_by=None) 50 | tools.assert_equal(choices(), [ 51 | (1, "Choice1"), 52 | (3, "A_Choice_3"), 53 | (2, "Choice2")]) 54 | 55 | 56 | def test_choices_functions(): 57 | # When an id is repeated, the last value is assumed 58 | choices = model_helpers.Choices([ 59 | ("choice1", 1), 60 | ("choice_xx", {"id": 3, "display": "xxx"}), 61 | ("choice2", {"id": 2, "extra_key": "extra_value"}), 62 | ("choice3", {"id": 3, "display": "A_Choice_3"}), 63 | ], order_by=None) 64 | 65 | tools.assert_equal(choices["choice1"], {"id": 1, "display": "Choice1"}) 66 | tools.assert_equal(choices["choice2"], {"id": 2, "display": "Choice2", "extra_key": "extra_value"}) 67 | tools.assert_equal(choices["choice3"], {"id": 3, "display": "A_Choice_3"}) 68 | 69 | tools.assert_equal(choices.choice1, 1) 70 | tools.assert_equal(choices.choice2, 2) 71 | tools.assert_equal(choices.choice3, 3) 72 | 73 | tools.assert_equal(choices.get_display_name(1), "Choice1") 74 | tools.assert_equal(choices.get_display_name(2), "Choice2") 75 | tools.assert_equal(choices.get_display_name(3), "A_Choice_3") 76 | 77 | tools.assert_equal(choices.get_code_name(1), "choice1") 78 | tools.assert_equal(choices.get_code_name(2), "choice2") 79 | tools.assert_equal(choices.get_code_name(3), "choice3") 80 | 81 | tools.assert_equal(choices.get_value(2, "extra_key"), "extra_value") 82 | tools.assert_raises(KeyError, choices.get_value, choice_id=1, choice_key="extra_key") 83 | tools.assert_equal(choices.get_value(1, "extra_key", raise_exception=False), None) 84 | 85 | 86 | def test_concat_choices(): 87 | 88 | choices1 = model_helpers.Choices({"X": 1, "Y": 2}) 89 | choices2 = choices1 + model_helpers.Choices({}) + {"B": 4, "A": 5} 90 | # Items of each list are ordered by when concatenated they are not re-ordered 91 | # items of first list will appear first then items of second list 92 | tools.assert_equal(choices2(), [ 93 | (1, "X"), 94 | (2, "Y"), 95 | (5, "A"), 96 | (4, "B")]) 97 | 98 | # Duplicate key 99 | tools.assert_raises(ValueError, choices1.__add__, {"X": 7}) 100 | 101 | 102 | def test_errors(): 103 | choices1 = model_helpers.Choices({"X": 1, "Y": 2}) 104 | tools.assert_raises(TypeError, choices1.__setattr__, "X", 7) 105 | tools.assert_raises(TypeError, choices1.__setattr__, "X", 7) 106 | tools.assert_raises(TypeError, choices1.update, {"X": 7}) 107 | 108 | 109 | def test_dir_method(): 110 | choices1 = model_helpers.Choices({"X": 1, "Y": 2}) 111 | tools.assert_in("X", dir(choices1)) 112 | tools.assert_in("Y", dir(choices1)) 113 | # parent attributes should also be mentioned 114 | tools.assert_in("items", dir(choices1)) 115 | -------------------------------------------------------------------------------- /tests/test_key_value_field.py: -------------------------------------------------------------------------------- 1 | import six 2 | 3 | from nose import tools as test 4 | from sample.models import Team 5 | from django.core.exceptions import ValidationError 6 | 7 | 8 | def test_key_value_field(): 9 | 10 | team = Team(name="Team1") 11 | test.assert_equal(team.options, {}) 12 | team.options = "name = Ramast" 13 | test.assert_equal(team.options, {"name": "Ramast"}) 14 | test.assert_equal(str(team.options), "name = Ramast\n") 15 | team.options = {"Age": 30} 16 | test.assert_equal(str(team.options), "Age = 30\n") 17 | # Notice int has been converted to string since we don't store value data type 18 | test.assert_equal(team.options, {"Age": "30"}) 19 | team.options.update({"Name": "Ramast"}) 20 | # Output should be 21 | # Name = Ramast 22 | # Age = 30 23 | # but since dictionary doesn't maintain order, I can't predict which one of the two lines will show first 24 | test.assert_in("Age = 30", str(team.options)) 25 | test.assert_in("Name = Ramast", str(team.options)) 26 | # Test invalid string 27 | try: 28 | team.options = "Name ?? Ramast" 29 | assert False, "Assigning invalid string should raise ValidationError" 30 | except ValidationError: 31 | pass 32 | 33 | 34 | def test_custom_key_value_separator(): 35 | team = Team(name="Team2") 36 | # Modify option field's separator pragmatically for this test case 37 | # Of course you should just define it in the model's field definition 38 | # options = KeyValueField(sep=":") 39 | options_field = filter(lambda field: field.name == "options", team._meta.fields) 40 | if six.PY3: 41 | options_field = next(options_field) 42 | else: 43 | options_field = options_field[0] 44 | options_field.separator = ":" 45 | # Test invalid string 46 | try: 47 | team.options = "Name = Ramast" 48 | assert False, "Assigning invalid string should raise ValidationError" 49 | except ValidationError: 50 | pass 51 | # Now use the new separator 52 | team.options = "Name : Ramast" 53 | test.assert_equal(team.options, {"Name": "Ramast"}) 54 | -------------------------------------------------------------------------------- /tests/test_upload_to.py: -------------------------------------------------------------------------------- 1 | from nose import tools 2 | from datetime import date 3 | import model_helpers 4 | from django.conf import settings 5 | 6 | # Specify a filename template that make use of all capabilities of upload_to template 7 | settings.UPLOAD_TO_OPTIONS = {"file_name_template": "{model_name}/%Y/{filename}-{instance.pk}.{extension}"} 8 | 9 | 10 | class FakeModel(object): 11 | pk = 1 12 | 13 | 14 | def test_upload_to(): 15 | 16 | fake_instance = FakeModel() 17 | upload_to = model_helpers.UploadTo(max_filename_length=10) # get upload_to function with short filename 18 | year = date.today().year 19 | 20 | tools.assert_equal( 21 | upload_to(fake_instance, "/tmp/filezx/myfile.png"), 22 | "FakeModel/%d/myfile-1.png" % year) 23 | tools.assert_equal( 24 | upload_to(fake_instance, "/tmp/filezx/1234567890123456.png"), 25 | "FakeModel/%d/1234567890-1.png" % year) 26 | tools.assert_raises(ValueError, upload_to, fake_instance, "/tmp/filezx/1234567890123456.php") 27 | tools.assert_raises(ValueError, upload_to, fake_instance, "/tmp/filezx/1234567890123456.pHp") 28 | tools.assert_raises(ValueError, upload_to, fake_instance, "/tmp/filezx/.pHp") 29 | # Validate model_helper's upload_to function (Shortcut for using UploadTo class) 30 | tools.assert_equal( 31 | model_helpers.upload_to(fake_instance, "/tmp/filezx/myfile.png"), 32 | "FakeModel/%d/myfile-1.png" % year) 33 | --------------------------------------------------------------------------------