Remove harsh video restriction and add video duration (#1811)
* Remove harsh video restriction and add video duration * Fix video meta data and tests * Modify video tests * Rescue video duration fetch error * Change log to puts in test
This commit is contained in:
parent
28bac35db2
commit
bce0b4fff6
22 changed files with 181 additions and 43 deletions
1
Gemfile
1
Gemfile
|
|
@ -58,6 +58,7 @@ gem "jquery-rails", "~> 4.3"
|
|||
gem "kaminari", "~> 1.1"
|
||||
gem "libhoney", "~> 1.11"
|
||||
gem "liquid", "~> 4.0"
|
||||
gem "ffprober", "~> 0.5"
|
||||
gem "nokogiri", "~> 1.10"
|
||||
gem "octokit", "~> 4.13"
|
||||
gem "omniauth", "~> 1.9"
|
||||
|
|
|
|||
|
|
@ -311,6 +311,7 @@ GEM
|
|||
loofah (>= 2.0)
|
||||
sax-machine (>= 1.0)
|
||||
ffi (1.9.25)
|
||||
ffprober (0.5.3)
|
||||
figaro (1.1.1)
|
||||
thor (~> 0.14)
|
||||
fission (0.5.0)
|
||||
|
|
@ -979,6 +980,7 @@ DEPENDENCIES
|
|||
fastly (~> 1.15)
|
||||
fastly-rails (~> 0.8)
|
||||
feedjira (~> 2.2)
|
||||
ffprober (~> 0.5)
|
||||
figaro (~> 1.1)
|
||||
fix-db-schema-conflicts!
|
||||
fog (~> 1.41)
|
||||
|
|
|
|||
77
app/assets/images/video-camera.svg
Normal file
77
app/assets/images/video-camera.svg
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 491.858 491.858" style="enable-background:new 0 0 491.858 491.858;" xml:space="preserve" fill="white">
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<path d="M357.714,423.331c0,9.328-10.676,16.891-23.847,16.891H23.847C10.676,440.222,0,432.659,0,423.331V203.735
|
||||
c0-9.33,10.676-16.892,23.847-16.892h310.02c13.171,0,23.847,7.564,23.847,16.892V423.331L357.714,423.331z" fill="white" fill-opacity="1.0" />
|
||||
<circle cx="89.428" cy="118.706" r="59.619" fill="white" fill-opacity="1.0" />
|
||||
<circle cx="253.381" cy="103.801" r="74.524" fill="white" fill-opacity="1.0" />
|
||||
<path d="M491.858,447.677c0,0-1.986,14.904-15.899,14.904c-13.912,0-103.34-83.42-103.34-94.397V258.882
|
||||
c0-10.976,87.443-94.398,103.34-94.398c15.899,0,15.899,14.905,15.899,14.905V447.677z" fill-opacity="1.0" fill="white"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -98,7 +98,12 @@ function buildArticleHTML(article) {
|
|||
} else if (article.class_name === "Article") {
|
||||
readingTimeHTML = '<a href="'+article.path+'" class="article-reading-time">3 min read</a>'
|
||||
}
|
||||
var videoHTML = '';
|
||||
if (article.cloudinary_video_url) {
|
||||
videoHTML = '<a href="'+article.path+'" class="single-article-video-preview" style="background-image:url('+article.cloudinary_video_url+')"><div class="single-article-video-duration"><img src="<%= asset_path("video-camera.svg") %>" />'+article.video_duration_in_minutes+'</div></a>'
|
||||
}
|
||||
return '<div class="single-article single-article-small-pic">\
|
||||
'+videoHTML+'\
|
||||
'+orgHeadline+'\
|
||||
<div class="small-pic">\
|
||||
<a href="/'+profileUsername+'" class="small-pic-link-wrapper">\
|
||||
|
|
|
|||
|
|
@ -233,6 +233,36 @@
|
|||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.single-article-video-preview {
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
position: relative;
|
||||
padding-top: 56%;
|
||||
display: block;
|
||||
background: $black no-repeat center center;
|
||||
background-size: cover;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.single-article-video-duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 7px;
|
||||
background: rgba(0,0,0,0.8);
|
||||
color: white;
|
||||
border-radius: 3px;
|
||||
padding: 2px 5px 3px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 500;
|
||||
img {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-right: 4px;
|
||||
vertical-align: -1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.article-organization-headline {
|
||||
display:block;
|
||||
margin-bottom:calc(-1px - 0.6vw);
|
||||
|
|
|
|||
|
|
@ -55,22 +55,12 @@ class Internal::UsersController < Internal::ApplicationController
|
|||
toggle_warn_user if user_params[:warn_user]
|
||||
toggle_trust_user if user_params[:trusted_user]
|
||||
toggle_ban_from_mentorship if user_params[:ban_from_mentorship]
|
||||
toggle_video_permission if user_params[:video_permission]
|
||||
end
|
||||
|
||||
def toggle_video_permission
|
||||
if user_params[:video_permission] == "1"
|
||||
@user.add_role :video_permission
|
||||
else
|
||||
@user.remove_role :video_permission
|
||||
end
|
||||
end
|
||||
|
||||
def toggle_ban_user
|
||||
if user_params[:ban_user] == "1"
|
||||
@user.add_role :banned
|
||||
@user.remove_role :trusted
|
||||
@user.remove_role :video_permission
|
||||
create_note("banned", user_params[:note_for_current_role])
|
||||
else
|
||||
@user.remove_role :banned
|
||||
|
|
@ -189,7 +179,6 @@ class Internal::UsersController < Internal::ApplicationController
|
|||
:note_for_mentorship_ban,
|
||||
:note_for_current_role,
|
||||
:reason_for_mentorship_ban,
|
||||
:trusted_user,
|
||||
:video_permission)
|
||||
:trusted_user)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ class Article < ApplicationRecord
|
|||
before_save :set_all_dates
|
||||
before_save :calculate_base_scores
|
||||
before_save :set_caches
|
||||
before_save :fetch_video_duration
|
||||
after_save :async_score_calc, if: :published
|
||||
after_save :bust_cache
|
||||
after_save :update_main_image_background_hex
|
||||
|
|
@ -75,7 +76,7 @@ class Article < ApplicationRecord
|
|||
:main_image, :main_image_background_hex_color, :updated_at, :slug,
|
||||
:video, :user_id, :organization_id, :video_source_url, :video_code,
|
||||
:video_thumbnail_url, :video_closed_caption_track_url,
|
||||
:published_at, :crossposted_at, :boost_states, :description, :reading_time)
|
||||
:published_at, :crossposted_at, :boost_states, :description, :reading_time, :video_duration_in_seconds)
|
||||
}
|
||||
|
||||
scope :limited_columns_internal_select, -> {
|
||||
|
|
@ -137,7 +138,8 @@ class Article < ApplicationRecord
|
|||
enqueue: :trigger_delayed_index do
|
||||
attributes :title, :path, :class_name, :comments_count, :reading_time,
|
||||
:tag_list, :positive_reactions_count, :id, :hotness_score, :score,
|
||||
:readable_publish_date, :flare_tag, :user_id, :organization_id
|
||||
:readable_publish_date, :flare_tag, :user_id, :organization_id,
|
||||
:cloudinary_video_url, :video_duration_in_minutes
|
||||
attribute :published_at_int do
|
||||
published_at.to_i
|
||||
end
|
||||
|
|
@ -384,6 +386,25 @@ class Article < ApplicationRecord
|
|||
user&.collections&.pluck(:slug)
|
||||
end
|
||||
|
||||
def cloudinary_video_url
|
||||
ApplicationController.helpers.cloudinary(video_thumbnail_url, 880)
|
||||
end
|
||||
|
||||
def video_duration_in_minutes
|
||||
minutes = (video_duration_in_seconds.to_i / 60) % 60
|
||||
seconds = video_duration_in_seconds.to_i % 60
|
||||
"#{minutes}:#{seconds}"
|
||||
end
|
||||
|
||||
def fetch_video_duration
|
||||
if video.present? && video_duration_in_seconds.zero?
|
||||
info = Ffprober::Parser.from_url video
|
||||
self.video_duration_in_seconds = info.json[:format][:duration].to_f
|
||||
end
|
||||
rescue StandardError => e
|
||||
puts e.message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_notifications
|
||||
|
|
@ -454,7 +475,7 @@ class Article < ApplicationRecord
|
|||
if published && video_state == "PROGRESSING"
|
||||
return errors.add(:published, "cannot be set to true if video is still processing")
|
||||
end
|
||||
if video.present? && !user.has_role?(:video_permission)
|
||||
if video.present? && user.created_at > 2.weeks.ago
|
||||
return errors.add(:video, "cannot be added member without permission")
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ class Role < ApplicationRecord
|
|||
level_2_member
|
||||
level_1_member
|
||||
workshop_pass
|
||||
video_permission
|
||||
chatroom_beta_tester
|
||||
banned_from_mentorship
|
||||
comment_banned
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ class User < ApplicationRecord
|
|||
|
||||
attr_accessor :scholar_email, :note, :ban_from_mentorship, :quick_match, :ban_user, :warn_user,
|
||||
:note_for_mentorship_ban, :reason_for_mentorship_ban,
|
||||
:note_for_current_role, :add_mentor, :add_mentee, :trusted_user, :video_permission
|
||||
:note_for_current_role, :add_mentor, :add_mentee, :trusted_user
|
||||
|
||||
rolify
|
||||
include AlgoliaSearch
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
class VideoPolicy < ApplicationPolicy
|
||||
def new?
|
||||
user.has_role?(:video_permission)
|
||||
user.created_at < 2.weeks.ago if user.created_at
|
||||
end
|
||||
|
||||
def create?
|
||||
user.has_role?(:video_permission)
|
||||
user.created_at < 2.weeks.ago if user.created_at
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
<div class="single-article single-article-small-pic">
|
||||
<% if story.video.present? %>
|
||||
<%= render "articles/video_player", meta_tags: false, article: story %>
|
||||
<% if story.video.present? && story.video_thumbnail_url.present? %>
|
||||
<a class="single-article-video-preview" style="background-image:url(<%= story.cloudinary_video_url %>)" >
|
||||
<div class="single-article-video-duration"><img src="<%= asset_path("video-camera.svg") %>" /><%= story.video_duration_in_minutes %></div>
|
||||
</a>
|
||||
<% end %>
|
||||
<% organization = story.organization %>
|
||||
<% if organization && !@organization_article_index %>
|
||||
<div class="article-organization-headline">
|
||||
<a href="/<%= organization.slug %>" class="article-organization-headline-inner"><img src="<%= organization.profile_image_90 %>"><%= organization.name %></a><a class="org-headline-filler" href="<%= story.path %>"> </a></div>
|
||||
<% end %>
|
||||
<a href="<%=story.user.path%>" class="small-pic-link-wrapper">
|
||||
<a href="<%= story.user.path%>" class="small-pic-link-wrapper">
|
||||
<div class="small-pic" >
|
||||
<% if story.cached_tag_list_array.include?("hiring") && story.organization_id.present? && !@organization_article_index %>
|
||||
<img src="<%= ProfileImage.new(story.organization).get(90) %>" alt="<%= story.organization.username %> profile" />
|
||||
|
|
@ -16,7 +18,7 @@
|
|||
<% end %>
|
||||
</div>
|
||||
</a>
|
||||
<a href="<%=story.path%>" class="small-pic-link-wrapper index-article-link" data-preload-image="<%=cloud_cover_url(story.main_image) %>" id="article-link-<%=story.id%>">
|
||||
<a href="<%= story.path %>" class="small-pic-link-wrapper index-article-link" data-preload-image="<%=cloud_cover_url(story.main_image) %>" id="article-link-<%= story.id%>">
|
||||
<div class="content">
|
||||
<h3>
|
||||
<%= render "articles/tag_identifier", story: story, tag: @tag %>
|
||||
|
|
@ -24,7 +26,7 @@
|
|||
</div>
|
||||
</a>
|
||||
<h4>
|
||||
<a href="<%=story.user.path%>"><%= story.user.name %>・<%= story.readable_publish_date %></a>
|
||||
<a href="<%= story.user.path %>"><%= story.user.name %>・<%= story.readable_publish_date %></a>
|
||||
|
||||
</h4>
|
||||
<div class="tags">
|
||||
|
|
@ -34,13 +36,13 @@
|
|||
</div>
|
||||
<% if story.comments_count > 0 %>
|
||||
<div class="article-engagement-count comments-count">
|
||||
<a href="<%=story.path%>#comments">
|
||||
<a href="<%= story.path %>#comments">
|
||||
<img src="<%= asset_path("comments-bubble.png") %>" alt="chat" /><span class="engagement-count-number"><%= story.comments_count %></span>
|
||||
</a>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="article-engagement-count reactions-count" data-reaction-count data-reactable-id="<%= story.id %>">
|
||||
<a href="<%=story.path%>">
|
||||
<a href="<%= story.path %>">
|
||||
<img src="<%= asset_path("reactions-stack.png") %>" alt="Reactions" /><span id="engagement-count-number-<%= story.id %>" class="engagement-count-number">
|
||||
<%= story.positive_reactions_count %>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
<div <% "itemscope itemtype=\"http://schema.org/VideoObject\"" if meta_tags %> class="video-player-header">
|
||||
<div temscope itemtype="http://schema.org/VideoObject" class="video-player-header">
|
||||
<% if meta_tags %>
|
||||
<meta itemprop="uploadDate" content="<%= @article.published_at %>" />
|
||||
<meta itemprop="name" content="<%= @article.title %>" />
|
||||
<meta itemprop="description" content="<%= @article.description %>" />
|
||||
<meta itemprop="thumbnailUrl" content="<%= cloudinary(article.video_thumbnail_url, 880) %>" />
|
||||
<meta itemprop="contentUrl" content="<%= article.video_source_url %>" />
|
||||
<meta itemprop="duration" content="<%= "PT%sM%sS" % article.video_duration_in_minutes.split(':') %>" />
|
||||
<% end %>
|
||||
<script src="//content.jwplatform.com/libraries/b1zWy2iv.js" async> </script>
|
||||
<div id="video-player-<%= article.id %>" class="video-player"></div>
|
||||
|
|
@ -40,6 +41,7 @@
|
|||
playerInstance.setup({
|
||||
file: "<%= article.video_source_url %>",
|
||||
mediaid: "<%= article.video_code %>",
|
||||
autostart: <%= internal_navigation? %>,
|
||||
image: "<%= cloudinary(article.video_thumbnail_url, 880) %>",
|
||||
playbackRateControls: true,
|
||||
tracks: [{
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@
|
|||
|
||||
<div class="dashboard-container" id="user-dashboard">
|
||||
<div class="actions">
|
||||
<a class="action <%= 'active' if params[:which] == "organization" || params[:which].blank? %>" href="/dashboard">
|
||||
<a class="action <%= "active" if params[:which] == "organization" || params[:which].blank? %>" href="/dashboard">
|
||||
<span>POSTS</span>
|
||||
<span>(<%= @user.articles_count %>)</span>
|
||||
</a>
|
||||
<a class="action <%= 'active' if params[:which] == "user_followers" || params[:which] == "organization_user_followers" %>" href="/dashboard/user_followers">
|
||||
<a class="action <%= "active" if params[:which] == "user_followers" || params[:which] == "organization_user_followers" %>" href="/dashboard/user_followers">
|
||||
<span>FOLLOWERS</span>
|
||||
<span>(<%= @user.followers_count %>)</span>
|
||||
</a>
|
||||
<a class="action <%= 'active' if params[:which].to_s.include?("following") %>" href="/dashboard/following">
|
||||
<a class="action <%= "active" if params[:which].to_s.include?("following") %>" href="/dashboard/following">
|
||||
<span>FOLLOWING</span>
|
||||
<span>(<%= @user.following_users_count + @user.following_tags_count %>)</span>
|
||||
</a>
|
||||
|
|
@ -78,7 +78,7 @@
|
|||
<% end %>
|
||||
<% elsif @articles.any? %>
|
||||
<%= render "analytics" %>
|
||||
<% if current_user.has_role?(:video_permission) %>
|
||||
<% if current_user.created_at < 2.weeks.ago %>
|
||||
<a class="video-upload-cta" href="/videos/new" data-no-instant>
|
||||
Upload a Video 🎥
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -52,8 +52,6 @@
|
|||
<%= form_for [:internal, @user] do |f| %>
|
||||
<p>Add Trusted Role (Community Moderator)
|
||||
<%= f.check_box :trusted_user, checked: @user.trusted %></p>
|
||||
<p>Allow Video Uploads
|
||||
<%= f.check_box :video_permission, checked: @user.has_role?(:video_permission) %> </p>
|
||||
<%= f.submit "Update Privileges" %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
key: "video-upload__#{SecureRandom.hex}",
|
||||
key_starts_with: "video-upload__",
|
||||
acl: "public-read",
|
||||
max_file_size: (current_user.has_role?(:super_admin) ? 15000 : 3500).megabytes,
|
||||
max_file_size: (current_user.has_role?(:super_admin) ? 20000 : 6000).megabytes,
|
||||
id: "s3-uploader",
|
||||
class: "upload-form",
|
||||
data: {:key => :val} do %>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
class AddVideoDurationInSecondsToArticles < ActiveRecord::Migration[5.1]
|
||||
def change
|
||||
add_column :articles, :video_duration_in_seconds, :float, default: 0
|
||||
end
|
||||
end
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 20190206222055) do
|
||||
ActiveRecord::Schema.define(version: 20190216185753) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
||||
|
|
@ -117,6 +117,7 @@ ActiveRecord::Schema.define(version: 20190206222055) do
|
|||
t.string "video"
|
||||
t.string "video_closed_caption_track_url"
|
||||
t.string "video_code"
|
||||
t.float "video_duration_in_seconds", default: 0.0
|
||||
t.string "video_source_url"
|
||||
t.string "video_state"
|
||||
t.string "video_thumbnail_url"
|
||||
|
|
|
|||
|
|
@ -37,8 +37,7 @@ FactoryBot.define do
|
|||
|
||||
trait :video do
|
||||
after(:build) do |article|
|
||||
article.video = "https://video.com"
|
||||
article.user.add_role :video_permission
|
||||
article.video = "https://s3.amazonaws.com/dev-to-input-v0/video-upload__2d7dc29e39a40c7059572bca75bb646b"
|
||||
article.save
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ FactoryBot.define do
|
|||
end
|
||||
|
||||
trait :video_permission do
|
||||
after(:build) { |user| user.add_role :video_permission }
|
||||
after(:build) { |user| user.created_at = 3.weeks.ago }
|
||||
end
|
||||
|
||||
trait :ignore_after_callback do
|
||||
|
|
|
|||
|
|
@ -206,7 +206,7 @@ RSpec.describe Article, type: :model do
|
|||
|
||||
describe "#video" do
|
||||
it "must be a url" do
|
||||
article.user.add_role(:video_permission)
|
||||
article.user.created_at = 3.weeks.ago
|
||||
article.video = "hey"
|
||||
expect(article).not_to be_valid
|
||||
article.video = "http://hey.com"
|
||||
|
|
@ -216,7 +216,14 @@ RSpec.describe Article, type: :model do
|
|||
it "must belong to permissioned user" do
|
||||
article.video = "http://hey.com"
|
||||
expect(article).not_to be_valid
|
||||
article.user.add_role(:video_permission)
|
||||
article.user.created_at = 3.weeks.ago
|
||||
expect(article).to be_valid
|
||||
end
|
||||
|
||||
it "saves with video" do
|
||||
article.user.created_at = 3.weeks.ago
|
||||
article.video = "https://s3.amazonaws.com/dev-to-input-v0/video-upload__2d7dc29e39a40c7059572bca75bb646b"
|
||||
article.save
|
||||
expect(article).to be_valid
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Comment, type: :model do
|
||||
let(:user) { create(:user) }
|
||||
let(:user) { create(:user, created_at: 3.weeks.ago) }
|
||||
let(:user2) { create(:user) }
|
||||
let(:article) { create(:article, user_id: user.id, published: true) }
|
||||
let(:article_with_video) { create(:article, :video, user_id: user.id, published: true) }
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@ RSpec.describe VideoPolicy do
|
|||
it { is_expected.to forbid_actions(%i[new create]) }
|
||||
end
|
||||
|
||||
context "when user does not have video permission" do
|
||||
context "when does have video permission" do
|
||||
let(:user) { build(:user) }
|
||||
|
||||
before { user.add_role :video_permission }
|
||||
before { user.created_at = 3.weeks.ago }
|
||||
|
||||
it { is_expected.to permit_actions(%i[new create]) }
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue