Commit 32f181ad authored by Jean-Baptiste's avatar Jean-Baptiste

Merge branch '167-federated-model' into 'master'

Resolve "Federated Model"

Closes #167

See merge request !96
parents bf684d21 f7c7d3f7
Pipeline #5356 passed with stage
in 1 minute and 20 seconds
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2019-09-05 15:42
# Generated by Django 1.11 on 2019-09-06 06:42
from __future__ import unicode_literals
from django.db import migrations, models
from django.db import migrations
import djangoldp.fields
class Migration(migrations.Migration):
......@@ -16,9 +17,9 @@ class Migration(migrations.Migration):
model_name='ldpsource',
name='container',
),
migrations.AlterField(
migrations.AddField(
model_name='ldpsource',
name='id',
field=models.URLField(primary_key=True, serialize=False),
name='urlid',
field=djangoldp.fields.LDPUrlField(null=True, unique=True),
),
]
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2019-09-11 09:31
from __future__ import unicode_literals
from django.db import migrations
import djangoldp.fields
class Migration(migrations.Migration):
dependencies = [
('djangoldp', '0002_auto_20190906_0642'),
]
operations = [
migrations.AlterField(
model_name='ldpsource',
name='urlid',
field=djangoldp.fields.LDPUrlField(blank=True, null=True, unique=True),
),
]
from django.conf import settings
from django.contrib.auth.models import User
from django.db import models
from django.db.models.base import ModelBase
from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver
from django.urls import get_resolver
from django.utils.datastructures import MultiValueDict, MultiValueDictKeyError
from django.utils.datastructures import MultiValueDictKeyError
from django.utils.decorators import classonlymethod
from djangoldp.fields import LDPUrlField
from djangoldp.permissions import LDPPermissions
User._meta.rdf_type = "foaf:user"
......@@ -12,6 +16,10 @@ User._meta.owner_field = "id"
class Model(models.Model):
urlid = LDPUrlField(blank=True, null=True, unique=True)
def __init__(self, *args, **kwargs):
super(Model, self).__init__(*args, **kwargs)
@classmethod
def get_view_set(cls):
......@@ -30,7 +38,10 @@ class Model(models.Model):
return cls.__clean_path(path)
def get_absolute_url(self):
return Model.resource_id(self)
if self.urlid is None or self.urlid != '':
return '{}{}'.format(settings.BASE_URL, Model.resource_id(self))
else:
return self.urlid
def get_container_id(self):
return Model.container_id(self)
......@@ -54,7 +65,7 @@ class Model(models.Model):
else:
object_name = instance_or_model._meta.object_name.lower()
view_name = '{}-detail'.format(object_name)
try :
try:
slug_field = '/{}'.format(get_resolver().reverse_dict[view_name][0][0][1][0])
except MultiValueDictKeyError:
slug_field = Model.get_meta(instance_or_model, 'lookup_field', 'pk')
......@@ -133,20 +144,34 @@ class Model(models.Model):
permissions = permission_class().filter_user_perms(user_or_group, obj_or_model, permissions)
return [{'mode': {'@type': name.split('_')[0]}} for name in permissions]
@classmethod
def is_external(cls, value):
try:
return value.urlid is not None and not value.urlid.startswith(settings.SITE_URL)
except:
return False
class LDPSource(Model):
id = models.URLField(primary_key=True)
federation = models.CharField(max_length=255)
class Meta:
rdf_type = 'ldp:Container'
ordering = ('federation',)
container_path = 'sources'
lookup_field = 'id'
lookup_field = 'federation'
permissions = (
('view_source', 'acl:Read'),
('control_source', 'acl:Control'),
)
def __str__(self):
return "{}: {}".format(self.federation, self.id)
return "{}: {}".format(self.federation, self.urlid)
@receiver([post_save])
def auto_urlid(sender, instance, **kwargs):
if isinstance(instance, Model) and (instance.urlid is None or instance.urlid == ''):
instance.urlid = instance.get_absolute_url()
instance.save()
......@@ -3,6 +3,7 @@ from typing import Any
from urllib import parse
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ImproperlyConfigured
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.urlresolvers import get_resolver, resolve, get_script_prefix, Resolver404
......@@ -184,7 +185,10 @@ class JsonLdField(HyperlinkedRelatedField):
class JsonLdRelatedField(JsonLdField):
def to_representation(self, value):
try:
return {'@id': super().to_representation(value)}
if Model.is_external(value):
return {'@id': value.urlid }
else:
return {'@id': super().to_representation(value)}
except ImproperlyConfigured:
return value.pk
......@@ -223,10 +227,20 @@ class JsonLdIdentityField(JsonLdField):
def to_representation(self, value: Any) -> Any:
try:
return Hyperlink(value.webid(), value)
if isinstance(value, str):
return Hyperlink(value, value)
else:
return Hyperlink(value.webid(), value)
except AttributeError:
return super().to_representation(value)
def get_attribute(self, instance):
if Model.is_external(instance):
return instance.urlid
else:
return super().get_attribute(instance)
class LDPSerializer(HyperlinkedModelSerializer):
url_field_name = "@id"
......@@ -254,10 +268,10 @@ class LDPSerializer(HyperlinkedModelSerializer):
for field in data:
if isinstance(data[field], dict) and '@id' in data[field]:
data[field]['@id'] = data[field]['@id'].format(Model.container_id(obj), str(getattr(obj, slug_field)))
if not ('@id' in data or 'id' in data):
if 'urlid' in data and data['urlid'] is not None:
data['@id'] = data.pop('urlid')['@id']
if not '@id' in data:
data['@id'] = '{}{}'.format(settings.SITE_URL, Model.resource(obj))
if 'id' in data:
data['@id'] = data.pop('id')
rdf_type = Model.get_meta(obj, 'rdf_type', None)
rdf_context = Model.get_meta(obj, 'rdf_context', None)
if rdf_type is not None:
......@@ -326,6 +340,8 @@ class LDPSerializer(HyperlinkedModelSerializer):
super().__init__(**kwargs)
def get_value(self, dictionary):
if self.field_name == 'urlid':
self.field_name = '@id'
try:
object_list = dictionary["@graph"]
if self.parent.instance is None:
......@@ -341,6 +357,9 @@ class LDPSerializer(HyperlinkedModelSerializer):
except KeyError:
value = super().get_value(dictionary)
if self.field_name == '@id' and value == './':
self.field_name = 'urlid'
return None
return self.manage_empty(value)
def manage_empty(self, value):
......@@ -371,6 +390,8 @@ class LDPSerializer(HyperlinkedModelSerializer):
fields = '__all__'
def to_internal_value(self, data):
if self.url_field_name in data and not 'urlid' in data and data[self.url_field_name].startswith('http'):
data['urlid'] = data[self.url_field_name]
if data is '':
return ''
if self.url_field_name in data:
......@@ -384,6 +405,7 @@ class LDPSerializer(HyperlinkedModelSerializer):
ret = OrderedDict()
errors = OrderedDict()
fields = list(filter(lambda x: x.field_name in data, self._writable_fields))
for field in fields:
......@@ -419,7 +441,8 @@ class LDPSerializer(HyperlinkedModelSerializer):
slug_field = Model.slug_field(self.__class__.Meta.model)
ret[slug_field] = match.kwargs[slug_field]
except Resolver404:
pass
if 'urlid' in data:
ret['urlid'] = data['urlid']
return ret
else:
......@@ -438,6 +461,15 @@ class LDPSerializer(HyperlinkedModelSerializer):
serializer.id = '{}{}/'.format(serializer.id, kwargs['context']['view'].nested_field)
return serializer
def to_internal_value(self, data):
user_case = self.Meta.model is get_user_model() and '@id' in data and not data['@id'].startswith(settings.BASE_URL)
if user_case:
data['username'] = 'external'
ret = super().to_internal_value(data)
if user_case:
ret['username'] = data['@id']
return ret
def get_value(self, dictionary):
try:
object_list = dictionary["@graph"]
......@@ -511,6 +543,8 @@ class LDPSerializer(HyperlinkedModelSerializer):
field_name in validated_data) and not field_name is None:
many_to_many.append((field_name, validated_data.pop(field_name)))
validated_data = self.remove_empty_value(validated_data)
if model is get_user_model() and 'urlid' in validated_data and not 'username' in validated_data:
validated_data['username'] = validated_data.pop('urlid')
instance = model.objects.create(**validated_data)
for field_name, value in many_to_many:
......@@ -540,9 +574,9 @@ class LDPSerializer(HyperlinkedModelSerializer):
else:
setattr(instance, attr, value)
instance.save()
self.save_or_update_nested_list(instance, nested_fields)
instance.save()
return instance
......
import validators
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
from django.urls import reverse_lazy
from django.utils.datetime_safe import date
from djangoldp.models import Model
......@@ -24,7 +26,7 @@ class Skill(Model):
class JobOffer(Model):
title = models.CharField(max_length=255, blank=True, null=True)
title = models.CharField(max_length=255, null=True)
skills = models.ManyToManyField(Skill, blank=True)
slug = models.SlugField(blank=True, null=True, unique=True)
date = models.DateTimeField(auto_now_add=True, blank=True)
......@@ -40,7 +42,7 @@ class JobOffer(Model):
authenticated_perms = ['inherit', 'change', 'add']
owner_perms = ['inherit', 'delete', 'control']
nested_fields = ["skills"]
serializer_fields = ["@id", "title", "skills", "recent_skills", "resources", "slug", "some_skill"]
serializer_fields = ["@id", "title", "skills", "recent_skills", "resources", "slug", "some_skill", "urlid"]
container_path = "job-offers/"
lookup_field = 'slug'
......@@ -58,6 +60,7 @@ class Conversation(models.Model):
class Resource(Model):
joboffers = models.ManyToManyField(JobOffer, blank=True, related_name='resources')
description = models.CharField(max_length=255)
class Meta:
anonymous_perms = ['view', 'add', 'delete', 'add', 'change', 'control']
......@@ -157,5 +160,28 @@ class Post(Model):
owner_perms = ['inherit']
class Circle(Model):
description = models.CharField(max_length=255)
team = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True)
class Meta:
nested_fields = ["team"]
anonymous_perms = ['view', 'add', 'delete', 'add', 'change', 'control']
authenticated_perms = ["inherit"]
rdf_type = 'hd:circle'
depth = 1
def webid(self):
# hack : We user webid as username for external user (since it's an uniq identifier too)
if validators.url(self.username):
webid = self.username
else:
webid = '{0}{1}'.format(settings.BASE_URL, reverse_lazy('user-detail', kwargs={'pk': self.pk}))
return webid
get_user_model()._meta.serializer_fields = ['@id', 'username', 'first_name', 'last_name', 'email', 'userprofile',
'conversation_set', ]
'conversation_set', 'circle_set']
get_user_model().webid = webid
get_user_model()._meta.anonymous_perms = ['view', 'add']
......@@ -49,7 +49,8 @@ settings.configure(DEBUG=False,
'djangoldp.tests',
),
SITE_URL='http://happy-dev.fr',
REST_FRAMEWORK={
BASE_URL='http://happy-dev.fr',
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'djangoldp.pagination.LDPPagination',
'PAGE_SIZE': 5
},
......@@ -71,7 +72,7 @@ failures = test_runner.run_tests([
'djangoldp.tests.tests_delete',
'djangoldp.tests.tests_sources',
'djangoldp.tests.tests_pagination',
'djangoldp.tests.tests_temp'
# 'djangoldp.tests.tests_temp'
])
if failures:
......
......@@ -42,14 +42,17 @@ class TestGET(APITestCase):
def test_get_filtered_fields(self):
skill = Skill.objects.create(title="Java", obligatoire="ok", slug="1")
skill2 = Skill.objects.create(title="Java", obligatoire="ok", slug="2")
skill3 = Skill.objects.create(urlid="http://external/skills/1")
job = JobOffer.objects.create(title="job", slug="1")
job.skills.add(skill)
job.skills.add(skill2)
job.skills.add(skill3)
job.save()
response = self.client.get('/job-offers/{}/'.format(job.slug), content_type='application/ld+json')
self.assertEqual(response.status_code, 200)
self.assertIn('recent_skills', response.data)
self.assertEqual(response.data['recent_skills']['@id'], "http://happy-dev.fr/job-offers/1/recent_skills/")
self.assertEqual(response.data['skills']['ldp:contains'][2]['@id'], "http://external/skills/1")
def test_get_reverse_filtered_fields(self):
skill = Skill.objects.create(title="Java", obligatoire="ok", slug="1")
......
......@@ -16,7 +16,7 @@ class LDPModelTest(TestCase):
def test_class_inheriting_ldp_model(self):
dummy = LDPDummy.objects.create(some="text")
self.assertEquals("/ldpdummys/", dummy.get_container_id())
self.assertEquals("/ldpdummys/{}/".format(dummy.pk), dummy.get_absolute_url())
self.assertEquals("http://happy-dev.fr/ldpdummys/{}/".format(dummy.pk), dummy.get_absolute_url())
self.assertEquals("/ldpdummys/", Model.container_id(dummy))
self.assertEquals("/ldpdummys/{}/".format(dummy.pk), Model.resource_id(dummy))
......@@ -33,6 +33,6 @@ class LDPModelTest(TestCase):
from django.urls import get_resolver
dummy = LDPDummy.objects.create(some="text")
view_name = '{}-list'.format(dummy._meta.object_name.lower())
path = '/{}{}/'.format(get_resolver().reverse_dict[view_name][0][0][0], dummy.pk)
path = 'http://happy-dev.fr/{}{}/'.format(get_resolver().reverse_dict[view_name][0][0][0], dummy.pk)
self.assertEquals(path, dummy.get_absolute_url())
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIRequestFactory, APIClient
from rest_framework.utils import json
from djangoldp.models import Model
from djangoldp.serializers import LDPSerializer
from djangoldp.tests.models import Skill, JobOffer, Invoice, LDPDummy, Resource
from djangoldp.tests.models import Skill, JobOffer, Invoice, LDPDummy, Resource, Post, Circle
class Save(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.client = APIClient()
self.user = get_user_model().objects.create_user(username='john', email='jlennon@beatles.com',
password='glass onion')
self.client.force_authenticate(self.user)
def tearDown(self):
pass
def test_save_m2m_graph_with_many_nested(self):
invoice = {
"@graph": [
......@@ -88,8 +100,8 @@ class Save(TestCase):
self.assertIs(result.skills.count(), 0)
def test_save_m2m_graph_with_nested(self):
skill1 = Skill.objects.create(title="skill1", obligatoire="obligatoire")
skill2 = Skill.objects.create(title="skill2", obligatoire="obligatoire")
skill1 = Skill.objects.create(title="skill1", obligatoire="obligatoire", slug="a")
skill2 = Skill.objects.create(title="skill2", obligatoire="obligatoire", slug="b")
job = {"@graph": [
{"title": "job test",
......@@ -111,9 +123,9 @@ class Save(TestCase):
self.assertEquals(result.skills.all()[0].title, "skill3 NEW") # creation on the fly
def test_save_without_nested_fields(self):
skill1 = Skill.objects.create(title="skill1", obligatoire="obligatoire")
skill2 = Skill.objects.create(title="skill2", obligatoire="obligatoire")
job = {"title": "job test"}
skill1 = Skill.objects.create(title="skill1", obligatoire="obligatoire", slug="a")
skill2 = Skill.objects.create(title="skill2", obligatoire="obligatoire", slug="b")
job = {"title": "job test", "slug": "c"}
meta_args = {'model': JobOffer, 'depth': 2, 'fields': ("@id", "title", "skills")}
......@@ -265,9 +277,24 @@ class Save(TestCase):
data=json.dumps(body),
content_type='application/ld+json')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['resources']['ldp:contains'][0]['@id'], "http://testserver/resources/{}/".format(resource.pk))
self.assertEqual(response.data['resources']['ldp:contains'][0]['@id'],
"http://testserver/resources/{}/".format(resource.pk))
self.assertEqual(response.data['title'], "new job")
def test_nested_container_federated(self):
resource = Resource.objects.create()
body = {
'http://happy-dev.fr/owl/#@id': "http://external.job/job/1",
}
response = self.client.post('/resources/{}/joboffers/'.format(resource.pk),
data=json.dumps(body),
content_type='application/ld+json')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['resources']['ldp:contains'][0]['@id'],
"http://testserver/resources/{}/".format(resource.pk))
self.assertEqual(response.data['@id'], "http://external.job/job/1")
def test_embedded_context_2(self):
body = {
'@id': "./",
......@@ -281,3 +308,33 @@ class Save(TestCase):
response = self.client.post('/posts/', data=json.dumps(body),
content_type='application/ld+json')
self.assertEqual(response.status_code, 201)
def test_auto_id(self):
body = {
'@id': "./",
'content': "post update",
'peer_user': "",
'@context': {
"@vocab": "http://happy-dev.fr/owl/#",
}
}
response = self.client.post('/posts/', data=json.dumps(body),
content_type='application/ld+json')
self.assertEqual(response.status_code, 201)
saved_post = Post.objects.get(pk=1)
self.assertEqual(saved_post.urlid, "http://happy-dev.fr/posts/1/")
def test_nested_container_user_federated(self):
circle = Circle.objects.create()
body = {
'http://happy-dev.fr/owl/#@id': "http://external.user/user/1/",
}
response = self.client.post('/circles/{}/team/'.format(circle.pk),
data=json.dumps(body),
content_type='application/ld+json')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['circle_set']['ldp:contains'][0]['@id'],
"http://testserver/circles/{}/".format(circle.pk))
self.assertEqual(response.data['@id'], "http://external.user/user/1/")
......@@ -13,6 +13,6 @@ class TestSource(APITestCase):
pass
def test_get_resource(self):
source = LDPSource.objects.create(federation="source_name", id="http://bar.foo/")
source = LDPSource.objects.create(federation="source_name", urlid="http://bar.foo/")
response = self.client.get('/sources/{}/'.format(source.federation), content_type='application/ld+json')
self.assertEqual(response.status_code, 200)
import json
from django.contrib.auth.models import User
from django.test import TestCase
from rest_framework.test import APIRequestFactory, APIClient
from djangoldp.tests.models import Resource, JobOffer, Invoice, Batch
class TestTemp(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.client = APIClient()
self.user = User.objects.create_user(username='john', email='jlennon@beatles.com', password='glass onion')
self.client.force_authenticate(self.user)
def tearDown(self):
pass
......@@ -4,7 +4,7 @@ from rest_framework.test import APIRequestFactory, APIClient
from rest_framework.utils import json
from djangoldp.serializers import LDPSerializer
from djangoldp.tests.models import Post, UserProfile, Resource
from djangoldp.tests.models import Post, UserProfile, Resource, Circle
from djangoldp.tests.models import Skill, JobOffer, Conversation, Message
......@@ -264,7 +264,7 @@ class Update(TestCase):
def test_put_resource(self):
post = Post.objects.create(content="content")
body = [{
'@id': '/posts/{}/'.format(post.pk),
'@id': 'http://testserver.com/posts/{}/'.format(post.pk),
'http://happy-dev.fr/owl/#content': "post content"}]
response = self.client.put('/posts/{}/'.format(post.pk), data=json.dumps(body),
content_type='application/ld+json')
......@@ -401,7 +401,7 @@ class Update(TestCase):
job = JobOffer.objects.create(title="first title", slug="job")
body = {
'http://happy-dev.fr/owl/#joboffers': {
'@id': 'http://testserver/job-offers/{}/'.format(job.slug),
'@id': 'http://testserver.com/job-offers/{}/'.format(job.slug),
}
}
......@@ -410,7 +410,7 @@ class Update(TestCase):
content_type='application/ld+json')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['joboffers']['ldp:contains'][0]['@id'],
"http://testserver/job-offers/{}/".format(job.slug))
"http://testserver.com/job-offers/{}/".format(job.slug))
self.assertEqual(response.data['joboffers']['ldp:contains'][0]['title'], "first title")
def test_m2m_new_link_bis(self):
......@@ -419,9 +419,9 @@ class Update(TestCase):
body = {
'http://happy-dev.fr/owl/#joboffers':
{
'@id': "http://testserver/resources/{}/joboffers/".format(resource.pk),
'@id': "http://testserver.com/resources/{}/joboffers/".format(resource.pk),
'ldp:contains': [
{'@id': 'http://testserver/job-offers/{}/'.format(job.slug),
{'@id': 'http://testserver.com/job-offers/{}/'.format(job.slug),
'http://happy-dev.fr/owl/#title': "new job",
},
]
......@@ -433,7 +433,7 @@ class Update(TestCase):
content_type='application/ld+json')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['joboffers']['ldp:contains'][0]['@id'],
"http://testserver/job-offers/{}/".format(job.slug))
"http://testserver.com/job-offers/{}/".format(job.slug))
self.assertEqual(response.data['joboffers']['ldp:contains'][0]['title'], "new job")
def test_m2m_new_link_embedded(self):
......@@ -450,7 +450,7 @@ class Update(TestCase):
content_type='application/ld+json')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['joboffers']['ldp:contains'][0]['@id'],
"http://testserver/job-offers/aaa/")
"http://happy-dev.fr/job-offers/aaa/")
self.assertEqual(response.data['joboffers']['ldp:contains'][0]['title'], "new job")
def test_m2m_existing_link(self):
......@@ -463,7 +463,7 @@ class Update(TestCase):
# '@id': "http://testserver/resources/{}/joboffers/".format(resource.pk),
'ldp:contains': [
{
'@id': 'http://testserver/job-offers/{}/'.format(job.slug),
'@id': 'http://testserver.com/job-offers/{}/'.format(job.slug),
'http://happy-dev.fr/owl/#title': "new job",
}
]
......@@ -475,5 +475,36 @@ class Update(TestCase):
content_type='application/ld+json')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['joboffers']['ldp:contains'][0]['@id'],
"http://testserver/job-offers/{}/".format(job.slug))
"http://testserver.com/job-offers/{}/".format(job.slug))
self.assertEqual(response.data['joboffers']['ldp:contains'][0]['title'], "new job")
def test_m2m_new_link_federated(self):
resource = Resource.objects.create()
body = {
'http://happy-dev.fr/owl/#joboffers': {
'http://happy-dev.fr/owl/#@id': 'http://external.job/job/1',
}
}
response = self.client.put('/resources/{}/'.format(resource.pk),
data=json.dumps(body),
content_type='application/ld+json')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['joboffers']['ldp:contains'][0]['@id'],
"http://external.job/job/1")
def test_m2m_user_link_federated(self):
circle = Circle.objects.create(description="cicle name")
body = {
'http://happy-dev.fr/owl/#description': 'circle name',
'http://happy-dev.fr/owl/#team': {
'http://happy-dev.fr/owl/#@id': 'http://external.user/user/1',
}
}
response = self.client.put('/circles/{}/'.format(circle.pk),
data=json.dumps(body),
content_type='application/ld+json')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['team']['ldp:contains'][0]['@id'],
"http://external.user/user/1")
......@@ -17,7 +17,7 @@ def __clean_path(path):
urlpatterns = [
url(r'^sources/(?P<federation>\w+)/', LDPSourceViewSet.urls(model=LDPSource, fields=['federation', 'id'],
url(r'^sources/(?P<federation>\w+)/', LDPSourceViewSet.urls(model=LDPSource, fields=['federation', 'urlid'],
permission_classes=[LDPPermissions], )),
url(r'^\.well-known/webfinger/?$', WebFingerView.as_view()),
]
......
......@@ -20,6 +20,7 @@ install_requires =
[options.extras_require]
dev =
validators
factory_boy>=2.11.0
[semantic_release]
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment