jace: Several models have a `url` column. The column will override the property, but we'll have to careful about unintended mixups and should consider renaming those columns.

https://github.com/search?q=org%3Ahasgeek+%22url+%3D+db.Column%22&type=Code
jace: I'm a little concerned about forcing a `url` property to be available on all models just because `UrlForMixin` is a base class of `BaseMixin`. We need this to be more intent-based:

RoleMixin should look for a dictionary named `proxy_properties` or `__proxy_properties__` mapping names to functions. These will be made available in the proxy as properties.

Syntax 1 (read-only properties):

```python
class MyModel(BaseMixin, db.Model):
    proxy_properties = {
        'url': lambda self: self.url_for(),
        }
```

Syntax 2 (read/write, but verbose):
```python
class MyModel(BaseMixin, db.Model):
    proxy_properties = {
        'url': {'get': lambda self: self.url_for()},
        }
```

Neither syntax offers a way to declare what roles these properties are available under. Presumably, that will be left to `__roles__`.

On the other hand, if `url` turns out to be widely used property, this syntax is unnecessary verbiage in all models. We could take yet another approach: a new mixin dedicated to adding URL properties:

```python
class UrlPropertyMixin(object):
    @with_roles(read={'all'})
    @property
    def url(self):
        return self.url_for()
```

Models that need `url` can simply inherit from this class. Other models are not affected.
iambibhas: I like the `UrlPropertyMixin` idea but can we call it `absolute_url` instead of `url`? And let it be available to *all* models? I'm getting the idea from Django where each model has a `get_absolute_url()` method that's used internally in Django whenever a url is needed for a model. It can be overridden as well. https://docs.djangoproject.com/en/2.1/ref/models/instances/#get-absolute-url

This lets us get out of this `url` name confusion.
jace: If we call it `absolute_url`, it doesn't need to be in a separate mixin.
jace: Renamed. Good to merge, @iambibhas?
diff --git a/CHANGES.rst b/CHANGES.rst
index f29b756..37a6299 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -25,6 +25,8 @@
   methods
 * New: ``coaster.views.endpoint_for``: discover an endpoint given a URL
 * Changed: ``UuidMixin`` no longer provides ``url_id``
+* New: ``coaster.sqlalchemy.UrlForMixin`` now provides an ``absolute_url``
+  property
 
 
 0.6.0
diff --git a/coaster/sqlalchemy/mixins.py b/coaster/sqlalchemy/mixins.py
index 48e1e6b..8eed61a 100644
--- a/coaster/sqlalchemy/mixins.py
+++ b/coaster/sqlalchemy/mixins.py
@@ -248,6 +248,11 @@ class UrlForMixin(object):
         # url_for from flask
         return url_for(endpoint, **params)
 
+    @with_roles(read={'all'})
+    @property
+    def absolute_url(self):
+        return self.url_for(_external=True)
+
     @classmethod
     def is_url_for(cls, _action, _endpoint=None, _external=None, **paramattrs):
         """

url property for models
Any REST API we build will require a `url` property for all the entity types. Coaster can provide it as a mixin. The `url` property will essentially return `self.url_for(_external=True)`.
diff --git a/tests/test_models.py b/tests/test_models.py
index bac428c..c7ebcf5 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -197,6 +197,7 @@ class DefaultValue(BaseMixin, db.Model):
     __tablename__ = 'default_value'
     value = db.Column(db.Unicode(100), default='default')
 
+
 auto_init_default(DefaultValue.value)
 
 
@@ -527,7 +528,7 @@ class TestCoasterModels(unittest.TestCase):
         self.session.commit()
         self.assertTrue(d.updated_at > updated_at)
 
-    def test_url_for(self):
+    def test_url_for_fail(self):
         d = UnnamedDocument(content="hello")
         self.assertEqual(d.url_for(), None)
 
diff --git a/tests/test_url_for.py b/tests/test_url_for.py
index a6b1ea1..5ed400b 100644
--- a/tests/test_url_for.py
+++ b/tests/test_url_for.py
@@ -94,3 +94,21 @@ class TestUrlFor(unittest.TestCase):
         self.assertEqual(doc2.url_for('edit'), 'http://localhost/1/document2/edit')
         self.assertEqual(doc2.url_for('edit', _external=False), '/1/document2/edit')
         self.assertEqual(doc2.url_for('edit', _external=True), 'http://localhost/1/document2/edit')
+
+    def test_url(self):
+        """
+        The .url property is the same as .url_for(_external=True)
+        """
+        # Make two documents
+        doc1 = NamedDocument(name=u'document1', title=u"Document 1")
+        self.session.add(doc1)
+        c1 = Container()  # Gets an autoincrementing id starting from 1
+        self.session.add(c1)
+        doc2 = ScopedNamedDocument(container=c1, name=u'document2', title=u"Document 2")
+        self.session.add(doc2)
+        self.session.commit()
+
+        assert doc1.absolute_url == doc1.url_for(_external=True)
+        assert doc1.absolute_url != doc1.url_for(_external=False)
+        assert doc2.absolute_url == doc2.url_for(_external=True)
+        assert doc2.absolute_url != doc2.url_for(_external=False)

pip install -e .[dev]
parse_log_pytest
requirements.txt
3.6
test_requirements.txt dev_requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alembic==1.7.7
attrs==22.2.0
bcrypt==4.0.1
bleach==4.1.0
blinker==1.5
certifi==2021.5.30
charset-normalizer==2.0.12
click==8.0.4
-e git+https://github.com/hasgeek/coaster.git@02a76cd93fc2e777d5ada4244618fbdcdfdfbb30#egg=coaster
coverage==6.2
coveralls==3.3.1
dataclasses==0.8
docflow==0.3.3
docopt==0.6.2
filelock==3.4.1
Flask==2.0.3
Flask-Assets==2.1.0
Flask-Migrate==4.0.1
Flask-Script==2.0.6
Flask-SQLAlchemy==2.5.1
greenlet==2.0.2
html2text==2020.1.16
html5lib==1.1
idna==3.10
importlib-metadata==4.8.3
importlib-resources==5.4.0
iniconfig==1.1.1
isoweek==1.3.3
itsdangerous==2.0.1
Jinja2==3.0.3
joblib==1.1.1
Mako==1.1.6
Markdown==3.3.7
MarkupSafe==2.0.1
nltk==3.6.7
packaging==21.3
pluggy==1.0.0
psycopg2==2.7.7
py==1.11.0
PyExecJS==1.5.1
Pygments==2.14.0
pyparsing==3.1.4
pytest==7.0.1
pytz==2025.2
regex==2023.8.8
requests==2.27.1
requests-file==2.1.0
semantic-version==2.10.0
shortuuid==1.0.13
simplejson==3.20.1
six==1.17.0
SQLAlchemy==1.4.54
SQLAlchemy-Utils==0.41.1
tldextract==3.1.2
tomli==1.2.3
tqdm==4.64.1
typing_extensions==4.1.1
UgliPyJS==0.2.5
Unidecode==1.3.8
urllib3==1.26.20
webassets==2.0
webencodings==0.5.1
Werkzeug==2.0.3
zipp==3.6.0

name: coaster
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=21.2.2=py36h06a4308_0
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alembic==1.7.7
      - attrs==22.2.0
      - bcrypt==4.0.1
      - bleach==4.1.0
      - blinker==1.5
      - charset-normalizer==2.0.12
      - click==8.0.4
      - coverage==6.2
      - coveralls==3.3.1
      - dataclasses==0.8
      - docflow==0.3.3
      - docopt==0.6.2
      - filelock==3.4.1
      - flask==2.0.3
      - flask-assets==2.1.0
      - flask-migrate==4.0.1
      - flask-script==2.0.6
      - flask-sqlalchemy==2.5.1
      - greenlet==2.0.2
      - html2text==2020.1.16
      - html5lib==1.1
      - idna==3.10
      - importlib-metadata==4.8.3
      - importlib-resources==5.4.0
      - iniconfig==1.1.1
      - isoweek==1.3.3
      - itsdangerous==2.0.1
      - jinja2==3.0.3
      - joblib==1.1.1
      - mako==1.1.6
      - markdown==3.3.7
      - markupsafe==2.0.1
      - nltk==3.6.7
      - packaging==21.3
      - pluggy==1.0.0
      - psycopg2==2.7.7
      - py==1.11.0
      - pyexecjs==1.5.1
      - pygments==2.14.0
      - pyparsing==3.1.4
      - pytest==7.0.1
      - pytz==2025.2
      - regex==2023.8.8
      - requests==2.27.1
      - requests-file==2.1.0
      - semantic-version==2.10.0
      - shortuuid==1.0.13
      - simplejson==3.20.1
      - six==1.17.0
      - sqlalchemy==1.4.54
      - sqlalchemy-utils==0.41.1
      - tldextract==3.1.2
      - tomli==1.2.3
      - tqdm==4.64.1
      - typing-extensions==4.1.1
      - uglipyjs==0.2.5
      - unidecode==1.3.8
      - urllib3==1.26.20
      - webassets==2.0
      - webencodings==0.5.1
      - werkzeug==2.0.3
      - zipp==3.6.0
prefix: /opt/conda/envs/coaster

tests/test_url_for.py::TestUrlFor::test_url
tests/test_models.py::TestCoasterModels::test_uuid_buid_suuid tests/test_models.py::TestCoasterModels::test_uuid_url_id tests/test_models.py::TestCoasterModels::test_uuid_url_id_name_suuid tests/test_models.py::TestCoasterModelsPG::test_auto_init_default tests/test_models.py::TestCoasterModelsPG::test_container tests/test_models.py::TestCoasterModelsPG::test_failsafe_add tests/test_models.py::TestCoasterModelsPG::test_failsafe_add_existing tests/test_models.py::TestCoasterModelsPG::test_failsafe_add_fail tests/test_models.py::TestCoasterModelsPG::test_failsafe_add_silent_fail tests/test_models.py::TestCoasterModelsPG::test_has_timestamps tests/test_models.py::TestCoasterModelsPG::test_id_named tests/test_models.py::TestCoasterModelsPG::test_jsondict tests/test_models.py::TestCoasterModelsPG::test_named tests/test_models.py::TestCoasterModelsPG::test_named_blank_allowed tests/test_models.py::TestCoasterModelsPG::test_named_blank_disallowed tests/test_models.py::TestCoasterModelsPG::test_parent_child_primary tests/test_models.py::TestCoasterModelsPG::test_parent_child_primary_sql_validator tests/test_models.py::TestCoasterModelsPG::test_query tests/test_models.py::TestCoasterModelsPG::test_reserved_name tests/test_models.py::TestCoasterModelsPG::test_scoped_id tests/test_models.py::TestCoasterModelsPG::test_scoped_id_named tests/test_models.py::TestCoasterModelsPG::test_scoped_id_without_parent tests/test_models.py::TestCoasterModelsPG::test_scoped_named tests/test_models.py::TestCoasterModelsPG::test_scoped_named_short_title tests/test_models.py::TestCoasterModelsPG::test_scoped_named_without_parent tests/test_models.py::TestCoasterModelsPG::test_timestamp tests/test_models.py::TestCoasterModelsPG::test_unnamed tests/test_models.py::TestCoasterModelsPG::test_url_for_fail tests/test_models.py::TestCoasterModelsPG::test_uuid_buid_suuid tests/test_models.py::TestCoasterModelsPG::test_uuid_default tests/test_models.py::TestCoasterModelsPG::test_uuid_key tests/test_models.py::TestCoasterModelsPG::test_uuid_url_id tests/test_models.py::TestCoasterModelsPG::test_uuid_url_id_name_suuid
tests/test_models.py::TestCoasterModels::test_auto_init_default tests/test_models.py::TestCoasterModels::test_container tests/test_models.py::TestCoasterModels::test_failsafe_add tests/test_models.py::TestCoasterModels::test_failsafe_add_existing tests/test_models.py::TestCoasterModels::test_failsafe_add_fail tests/test_models.py::TestCoasterModels::test_failsafe_add_silent_fail tests/test_models.py::TestCoasterModels::test_has_timestamps tests/test_models.py::TestCoasterModels::test_id_named tests/test_models.py::TestCoasterModels::test_jsondict tests/test_models.py::TestCoasterModels::test_named tests/test_models.py::TestCoasterModels::test_named_blank_allowed tests/test_models.py::TestCoasterModels::test_named_blank_disallowed tests/test_models.py::TestCoasterModels::test_parent_child_primary tests/test_models.py::TestCoasterModels::test_query tests/test_models.py::TestCoasterModels::test_reserved_name tests/test_models.py::TestCoasterModels::test_scoped_id tests/test_models.py::TestCoasterModels::test_scoped_id_named tests/test_models.py::TestCoasterModels::test_scoped_id_without_parent tests/test_models.py::TestCoasterModels::test_scoped_named tests/test_models.py::TestCoasterModels::test_scoped_named_short_title tests/test_models.py::TestCoasterModels::test_scoped_named_without_parent tests/test_models.py::TestCoasterModels::test_timestamp tests/test_models.py::TestCoasterModels::test_unnamed tests/test_models.py::TestCoasterModels::test_url_for_fail tests/test_models.py::TestCoasterModels::test_uuid_default tests/test_models.py::TestCoasterModels::test_uuid_key tests/test_url_for.py::TestUrlFor::test_class_has_url_for tests/test_url_for.py::TestUrlFor::test_url_for
coaster/sqlalchemy/mixins.py CHANGES.rst
coaster/sqlalchemy/mixins.py CHANGES.rst